// ==UserScript==
// @name Steam 愿望单侧边栏
// @name:en Steam Wishlist Sidebar
// @namespace https://github.com/steam-card-view
// @version 1.2.2
// @description 在Steam愿望单页面增加侧边栏浮窗面板(仪表盘/游戏库/分析/趋势),支持封面/横板封面/列表三种视图切换、KPI仪表、价格区间、标签分类、年度趋势热力图、AI犀利点评、史低价格查询与AI入手时机预测、CSV/JSON导出。更新日志见脚本目录 README。
// @description:en Steam Wishlist sidebar panel with dashboard, cover/wide-cover/list views, analytics, trends, heatmap, AI sharp review, historical low price analysis with AI purchase timing prediction, CSV/JSON export.
// @match https://store.steampowered.com/wishlist/id/*
// @match https://store.steampowered.com/wishlist/profiles/*
// @match https://store.steampowered.com/wishlistnew/id/*
// @match https://store.steampowered.com/wishlistnew/profiles/*
// @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%238b5cf6'/%3E%3Cstop offset='0.5' stop-color='%233b82f6'/%3E%3Cstop offset='1' stop-color='%2306b6d4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Cpath d='M32 14c-7 0-13 5-13 12 0 4 2 7 5 9v6c0 1 1 2 2 2h12c1 0 2-1 2-2v-6c3-2 5-5 5-9 0-7-6-12-13-12z' fill='%23fff' opacity='.95'/%3E%3Ccircle cx='32' cy='25' r='5' fill='url(%23g)'/%3E%3C/svg%3E
// @grant GM_addStyle
// @grant GM_info
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_cookie
// @grant unsafeWindow
// @connect store.steampowered.com
// @connect steamcommunity.com
// @connect api.steampowered.com
// @connect api.deepseek.com
// @connect api.openai.com
// @connect api.isthereanydeal.com
// @connect isthereanydeal.com
// @connect api.cheapshark.com
// @connect cheapshark.com
// @connect api.enhancedsteam.com
// @connect enhancedsteam.com
// @connect augmentedsteam.com
// @connect api.augmentedsteam.com
// @connect open.er-api.com
// @connect raw.githubusercontent.com
// @run-at document-idle
// @license MIT
// @homepageURL https://github.com/smallrob/steam-card-view
// @supportURL https://github.com/smallrob/steam-card-view/issues
// ==/UserScript==
(function () {
'use strict';
const _v = typeof GM_info !== 'undefined' ? GM_info.script.version : 'unknown';
console.log(`%c[SWE] v${_v} 愿望单侧边栏面板已启动`, 'color:#a78bfa;font-weight:bold;font-size:13px');
// ============================================================
// 配置
// ============================================================
const CONFIG = {
API_DELAY: 200,
APPDETAILS_DELAY: 250,
DEBOUNCE: 200,
FETCH_TIMEOUT: 20000,
BLOB_CLEANUP: 500,
ANIMATION_DURATION: 300,
TOAST_DURATION: 3000,
INIT_DELAY: 1500,
ITEMS_PER_PAGE: 48,
MAX_PAGES: 300,
PAGE_DELAY: 100,
CACHE_TTL: 7 * 24 * 60 * 60 * 1000,
CACHE_VERSION: '2.3.26',
BATCH_SIZE: 25,
PANEL_WIDTH: 720,
ITEMS_PER_PAGE_CARD: 16,
ITEMS_PER_PAGE_LIST: 30,
ITEMS_PER_PAGE_COVER: 24,
// v1.1.0 愿望单增强
STEAM_WORKERS: 3, // Steam API 并发 worker 数
STEAM_REQ_INTERVAL: 1000, // Steam API 请求间隔(毫秒)
PRICEHIST_WORKERS: 3, // 价格历史并发 worker 数
PRICEHIST_REQ_INTERVAL: 300, // 价格历史请求间隔(毫秒)
PRICEHIST_CACHE_TTL: 6 * 60 * 60 * 1000, // 价格历史缓存 6 小时
OWNERSHIP_CACHE_TTL: 7 * 24 * 60 * 60 * 1000, // 拥有状态缓存 7 天
FX_CACHE_TTL: 60 * 60 * 1000, // 汇率缓存 1 小时
BATCH_REMOVE_DELAY: 800, // 批量移除每项间隔
};
const CACHE_KEYS = {
FP: 'swe_wishlist_v2_fp',
META: 'swe_wishlist_v2_meta',
VERSION: 'swe_wishlist_v2_version'
};
const DB_NAME = 'swe_wishlist_v2_db';
const DB_STORE = 'items';
// ============================================================
// 货币表
// ============================================================
const CURRENCIES = [
{ id: 'HKD', label: '港币', symbol: 'HK$', match: /HK\$\s*[\d,. ]+/, cc: 'HK' },
{ id: 'TWD', label: '新台币', symbol: 'NT$', match: /NT\$\s*[\d,. ]+/, cc: 'TW' },
{ id: 'AUD', label: '澳元', symbol: 'A$', match: /A\$\s*[\d,. ]+/, cc: 'AU' },
{ id: 'CAD', label: '加元', symbol: 'CDN$', match: /CDN\$\s*[\d,. ]+/, cc: 'CA' },
{ id: 'ARS', label: '阿根廷比索', symbol: 'ARS$', match: /ARS\$\s*[\d,. ]+/, cc: 'AR' },
{ id: 'SGD', label: '新加坡元', symbol: 'S$', match: /(? [c.id, c]));
const getCurrencyById = id => CURRENCY_MAP.get(id) || CURRENCY_MAP.get('CNY');
const FLAGS = {
CNY: '',
USD: '',
EUR: '',
GBP: '',
JPY: '',
KRW: '',
RUB: '',
TRY: '',
AUD: '',
CAD: '',
HKD: '',
TWD: '',
SGD: '',
THB: '',
MYR: '',
PHP: '',
IDR: '',
VND: '',
INR: '',
ARS: '',
BRL: '',
MXN: ''
};
// ============================================================
// SVG 图标
// ============================================================
const _S = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
const ICONS = {
grid: ``,
list: ``,
coverGrid: ``,
close: ``,
settings: ``,
refresh: ``,
trend: ``,
barChart: ``,
clock: ``,
heart: ``,
dollar: ``,
tag: ``,
gift: ``,
calendar: ``,
download: ``,
trash: ``,
dashboard: ``,
fire: ``,
search: ``,
chevronLeft: ``,
chevronRight: ``,
sparkles: ``,
gamepad: ``,
star: ``,
trophy: ``,
// v1.1.0 愿望单增强图标
checkSquare: ``,
square: ``,
checkDouble: ``,
layers: ``,
trending: ``,
userCheck: ``,
// v1.2.0 史低分析图标
target: ``,
zap: ``,
brain: ``,
history: ``,
percent: ``,
};
// ============================================================
// CSS 设计系统
// ============================================================
const CSS = `
/* ====== 设计令牌 ====== */
:root {
--swe-bg: rgba(13, 18, 35, 0.98);
--swe-bg-solid: #0d1223;
--swe-bg-card: rgba(20, 27, 50, 0.9);
--swe-bg-hover: rgba(40, 55, 90, 0.6);
--swe-border: rgba(139, 92, 246, 0.18);
--swe-border-hover: rgba(139, 92, 246, 0.35);
--swe-border-focus: rgba(102, 192, 244, 0.6);
--swe-text: #e2e8f0;
--swe-text-2: #94a3b8;
--swe-text-bright: #f8fafc;
--swe-accent: #8b5cf6;
--swe-accent-cyan: #06b6d4;
--swe-accent-blue: #3b82f6;
--swe-green: #4ade80;
--swe-red: #f87171;
--swe-orange: #fb923c;
--swe-amber: #fbbf24;
--swe-radius: 12px;
--swe-radius-sm: 8px;
--swe-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
--swe-w: ${CONFIG.PANEL_WIDTH}px;
color-scheme: dark;
}
/* ====== FAB 浮动按钮 ====== */
#swe-fab {
position: fixed !important;
top: 50% !important;
right: 0 !important;
transform: translateY(-50%) !important;
width: 24px !important;
height: 72px !important;
background: linear-gradient(135deg, #8b5cf6 0%, #3b82f6 50%, #06b6d4 100%) !important;
border: none !important;
border-radius: 10px 0 0 10px !important;
cursor: pointer !important;
z-index: 1000003 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
color: #fff !important;
box-shadow: -4px 0 18px rgba(102, 51, 238, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.15) !important;
transition: right 0.4s cubic-bezier(0.4, 0, 0.2, 1), width 0.3s ease, box-shadow 0.3s ease !important;
overflow: hidden;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
#swe-fab::after {
content: "";
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 8px solid #fff;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
}
#swe-fab:hover {
width: 30px !important;
box-shadow: -6px 0 24px rgba(102, 51, 238, 0.55), inset 0 0 0 1px rgba(255, 255, 255, 0.2) !important;
}
#swe-fab:hover::after { border-right-width: 9px; }
#swe-fab:focus-visible { outline: 2px solid #66c0f4; outline-offset: 2px; }
#swe-fab.swe-open { right: var(--swe-w) !important; }
#swe-fab.swe-open::after { transform: rotate(180deg); }
/* v2.3.26: 隐藏游戏库展示脚本的浮动按钮,确保愿望单侧边栏独占 */
#sgis-fab { display: none !important; }
/* v2.3.26: 游戏库导航菜单入口样式 */
.swe-nav-entry {
font-family: "Motiva Sans", Sans-Serif !important;
font-size: 14px !important;
color: #b8b6b4 !important;
text-decoration: none !important;
display: inline-flex !important;
align-items: center !important;
gap: 4px !important;
padding: 0 12px !important;
height: 100% !important;
transition: color 0.2s ease !important;
}
.swe-nav-entry:hover { color: #fff !important; }
.swe-nav-entry svg { width: 14px; height: 14px; opacity: 0.7; }
.swe-nav-entry:hover svg { opacity: 1; }
/* ====== 侧滑面板 ====== */
#swe-panel {
position: fixed !important;
top: 0 !important;
right: 0 !important;
width: var(--swe-w) !important;
min-width: var(--swe-w) !important;
max-width: var(--swe-w) !important;
box-sizing: border-box !important;
height: 100vh !important;
background: var(--swe-bg) !important;
border-left: 1px solid rgba(139, 92, 246, 0.22) !important;
box-shadow: -16px 0 60px rgba(0, 0, 0, 0.65), inset 1px 0 0 rgba(255, 255, 255, 0.04) !important;
z-index: 1000002 !important;
display: flex !important;
flex-direction: column !important;
transform: translateX(100%) !important;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
font-family: "Motiva Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
color: var(--swe-text) !important;
backdrop-filter: blur(20px) saturate(160%) !important;
-webkit-backdrop-filter: blur(20px) saturate(160%) !important;
overflow: hidden;
overscroll-behavior: contain;
}
#swe-panel::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.5) 50%, transparent 100%);
pointer-events: none;
z-index: 5;
}
#swe-panel.swe-open { transform: translateX(0) !important; }
@media (max-width: 900px) {
:root { --swe-w: 100vw !important; }
#swe-fab.swe-open { right: 100vw !important; }
}
/* ====== 面板头部 ====== */
.swe-header {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.22) 0%, rgba(59, 130, 246, 0.12) 50%, var(--swe-bg-card) 100%);
padding: 14px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(139, 92, 246, 0.2);
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.swe-title {
font-size: 15px;
font-weight: 700;
color: #fff;
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
flex: 1;
overflow: hidden;
}
.swe-title-text {
background: linear-gradient(135deg, #fff 0%, #c4b5fd 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.swe-title-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
background: rgba(139, 92, 246, 0.2);
color: #c4b5fd;
flex-shrink: 0;
white-space: nowrap;
}
.swe-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.swe-icon-btn {
width: 30px;
height: 30px;
border: 1px solid rgba(139, 92, 246, 0.15);
border-radius: var(--swe-radius-sm);
background: rgba(139, 92, 246, 0.08);
color: var(--swe-text-2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, border-color 0.2s, color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.swe-icon-btn:hover {
background: rgba(139, 92, 246, 0.18);
border-color: rgba(139, 92, 246, 0.3);
color: #fff;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25);
}
.swe-icon-btn:focus-visible { outline: 2px solid var(--swe-border-focus); outline-offset: 1px; }
.swe-icon-btn svg { width: 15px; height: 15px; }
/* 加载/更新按钮:旋转动画 + hover 提示 */
.swe-icon-btn-spin { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; animation: swe-spin 0.9s linear infinite; transform-origin: 50% 50%; color: #a78bfa; }
.swe-icon-btn-spin svg { width: 15px; height: 15px; }
.swe-icon-btn:disabled { cursor: wait; opacity: 0.85; }
.swe-icon-btn:disabled:hover { transform: none; box-shadow: none; }
@keyframes swe-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* ====== Tab 导航 ====== */
.swe-tabs {
display: flex;
background: linear-gradient(180deg, rgba(15, 19, 42, 0.7) 0%, rgba(11, 14, 28, 0.85) 100%);
padding: 5px;
gap: 4px;
border-bottom: 1px solid rgba(139, 92, 246, 0.12);
flex-shrink: 0;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
position: relative;
z-index: 2;
overflow-x: auto;
flex-wrap: nowrap;
}
.swe-tabs::-webkit-scrollbar { height: 0; }
.swe-tab {
flex: 1;
border: none;
background: transparent;
color: var(--swe-text-2);
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
border-radius: var(--swe-radius-sm);
cursor: pointer;
transition: color 0.2s, background 0.2s, box-shadow 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
white-space: nowrap;
flex-shrink: 0;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.swe-tab:hover { color: #c4b5fd; }
.swe-tab:focus-visible { outline: 2px solid var(--swe-border-focus); outline-offset: 1px; }
.swe-tab.active {
color: #fff;
background: rgba(139, 92, 246, 0.15);
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25), inset 0 0 0 1px rgba(139, 92, 246, 0.5);
}
.swe-tab svg { width: 14px; height: 14px; flex-shrink: 0; }
/* ====== 面板主体 ====== */
.swe-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
/* v1.1.7: 增加 Firefox 滚动条兼容(scrollbar-width/scrollbar-color) +
overscroll-behavior: contain 防止滚动穿透到页面 */
.swe-content { flex: 1; overflow-y: auto; padding: 10px 14px 14px; min-height: 0; scroll-behavior: smooth; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: rgba(139, 92, 246, 0.3) transparent; }
.swe-content::-webkit-scrollbar { width: 6px; }
.swe-content::-webkit-scrollbar-track { background: transparent; }
.swe-content::-webkit-scrollbar-thumb { background: rgba(139, 92, 246, 0.2); border-radius: 3px; }
.swe-content::-webkit-scrollbar-thumb:hover { background: rgba(139, 92, 246, 0.35); }
.swe-content::-webkit-scrollbar-corner { background: transparent; }
/* ====== 工具栏 ====== */
.swe-toolbar {
display: flex;
align-items: center;
padding: 8px 16px;
background: rgba(15, 19, 42, 0.4);
border-bottom: 1px solid rgba(139, 92, 246, 0.1);
gap: 8px;
flex-shrink: 0;
flex-wrap: wrap;
}
.swe-search-wrap {
flex: 1;
min-width: 140px;
position: relative;
display: flex;
align-items: center;
}
.swe-search-wrap svg {
position: absolute;
left: 10px;
width: 14px;
height: 14px;
color: var(--swe-text-2);
pointer-events: none;
z-index: 1;
}
.swe-search {
flex: 1;
width: 100%;
padding: 7px 12px 7px 32px;
background: rgba(15, 19, 42, 0.6);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: var(--swe-radius-sm);
color: var(--swe-text-bright);
font-size: 12px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
font-family: inherit;
}
.swe-search:focus { border-color: rgba(139, 92, 246, 0.4); box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); }
.swe-search::placeholder { color: var(--swe-text-2); font-size: 12px; }
.swe-search::-webkit-search-cancel-button { -webkit-appearance: none; }
/* ====== 按钮 ====== */
.swe-btn {
padding: 6px 12px;
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: var(--swe-radius-sm);
background: rgba(139, 92, 246, 0.08);
color: var(--swe-text);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s, transform 0.2s, box-shadow 0.2s;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
font-family: inherit;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.swe-btn:hover { background: rgba(139, 92, 246, 0.18); border-color: rgba(139, 92, 246, 0.3); color: #fff; transform: translateY(-1px); }
.swe-btn:focus-visible { outline: 2px solid var(--swe-border-focus); outline-offset: 1px; }
.swe-btn:active { transform: translateY(0); }
.swe-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.swe-btn.active { background: rgba(139, 92, 246, 0.2); border-color: rgba(139, 92, 246, 0.4); color: #c4b5fd; }
.swe-btn-primary { background: linear-gradient(135deg, #8b5cf6, #3b82f6); border-color: transparent; color: #fff; }
.swe-btn-primary:hover { background: linear-gradient(135deg, #a78bfa, #60a5fa); color: #fff; }
.swe-btn-icon { width: 30px; height: 30px; padding: 0; justify-content: center; }
.swe-btn-icon svg { width: 14px; height: 14px; }
.swe-btn-sm { padding: 4px 10px; font-size: 11px; }
/* ====== 筛选按钮组 ====== */
.swe-filter-group { display: flex; gap: 4px; flex-wrap: wrap; }
/* ====== KPI 卡片 ====== */
.swe-kpi-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.swe-kpi-card {
position: relative;
background: var(--swe-bg-card);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: var(--swe-radius-sm);
padding: 10px 10px 8px;
overflow: hidden;
transition: border-color 0.2s, transform 0.2s;
}
.swe-kpi-card:hover { border-color: rgba(139, 92, 246, 0.3); transform: translateY(-1px); }
.swe-kpi-card::before {
content: '';
position: absolute;
top: -10px;
right: -10px;
width: 36px;
height: 36px;
border-radius: 50%;
opacity: 0.12;
filter: blur(6px);
}
.swe-kpi-card.kpi-total::before { background: #f43f5e; }
.swe-kpi-card.kpi-value::before { background: #10b981; }
.swe-kpi-card.kpi-sale::before { background: #f59e0b; }
.swe-kpi-card.kpi-free::before { background: #6366f1; }
.swe-kpi-card.kpi-discount::before { background: #06b6d4; }
.swe-kpi-card.kpi-year::before { background: #a855f7; }
.swe-kpi-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
margin-bottom: 6px;
}
.swe-kpi-icon svg { width: 14px; height: 14px; }
.swe-kpi-card.kpi-total .swe-kpi-icon { background: rgba(244,63,94,0.15); color: #f43f5e; }
.swe-kpi-card.kpi-value .swe-kpi-icon { background: rgba(16,185,129,0.15); color: #10b981; }
.swe-kpi-card.kpi-sale .swe-kpi-icon { background: rgba(245,158,11,0.15); color: #f59e0b; }
.swe-kpi-card.kpi-free .swe-kpi-icon { background: rgba(99,102,241,0.15); color: #6366f1; }
.swe-kpi-card.kpi-discount .swe-kpi-icon { background: rgba(6,182,212,0.15); color: #06b6d4; }
.swe-kpi-card.kpi-year .swe-kpi-icon { background: rgba(168,85,247,0.15); color: #a855f7; }
.swe-kpi-value { font-size: 18px; font-weight: 800; color: #fff; line-height: 1.2; font-variant-numeric: tabular-nums; }
.swe-kpi-label { font-size: 11px; color: var(--swe-text-2); margin-top: 2px; }
.swe-kpi-sub { font-size: 10px; color: var(--swe-text-2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.85; }
/* ====== 概览页(参考 friend-manager 仪表盘)====== */
/* v1.2.2: KPI 区域固定在滚动容器外部,彻底解决内容穿透问题 */
.swe-overview-kpi-header {
flex-shrink: 0;
background: var(--swe-bg-solid);
padding: 8px 14px 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 0 rgba(139, 92, 246, 0.08);
position: relative;
z-index: 20;
}
.swe-overview-kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.swe-overview-kpi {
display: flex;
align-items: center;
gap: 8px;
background: var(--swe-bg-card);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: var(--swe-radius-sm);
padding: 8px 10px;
min-width: 0;
transition: border-color 0.2s, transform 0.2s;
}
.swe-overview-kpi:hover { border-color: rgba(139, 92, 246, 0.35); transform: translateY(-1px); }
.swe-overview-kpi-ico {
width: 30px;
height: 30px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.swe-overview-kpi-ico svg { width: 15px; height: 15px; }
.swe-overview-kpi-body { flex: 1; min-width: 0; }
.swe-overview-kpi-val {
font-size: 15px;
font-weight: 800;
color: #fff;
line-height: 1.15;
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.3px;
}
.swe-overview-kpi-lbl { font-size: 10px; color: var(--swe-text-2); font-weight: 600; margin-top: 1px; }
.swe-overview-kpi-sub { font-size: 9.5px; color: var(--swe-text-2); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.85; }
.swe-overview-kpi-sub svg { width: 9px; height: 7px; vertical-align: middle; margin-right: 2px; }
.swe-overview-stack {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
position: relative;
z-index: 1;
}
@media (max-width: 720px) {
.swe-overview-kpis { grid-template-columns: repeat(2, 1fr); }
}
.swe-overview-card {
background: linear-gradient(180deg, rgba(20, 27, 50, 0.85) 0%, rgba(15, 19, 42, 0.85) 100%);
border: 1px solid rgba(139, 92, 246, 0.1);
border-radius: var(--swe-radius);
padding: 12px 14px;
min-width: 0;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.02) inset, 0 4px 12px rgba(0, 0, 0, 0.18);
transition: border-color 0.2s;
}
.swe-overview-card:hover { border-color: rgba(139, 92, 246, 0.22); }
.swe-overview-card-title {
display: flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 700;
color: #e2e8f0;
margin-bottom: 12px;
letter-spacing: 0.2px;
}
.swe-overview-card-title svg { width: 14px; height: 14px; color: #a78bfa; flex-shrink: 0; }
.swe-overview-card-title span:first-of-type { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swe-overview-card-sub { font-size: 10px; color: var(--swe-text-2); font-weight: 500; margin-left: auto; white-space: nowrap; padding: 2px 7px; background: rgba(139, 92, 246, 0.08); border-radius: 10px; }
/* 价值构成:环形居中 + 图例底部(垂直布局) */
.swe-overview-value {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 4px 0 2px;
}
.swe-overview-donut-center-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.swe-overview-donut { position: relative; flex-shrink: 0; }
.swe-overview-donut-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
pointer-events: none;
padding: 0 12px;
}
.swe-overview-donut-val {
font-size: 14px;
font-weight: 800;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.swe-overview-donut-lbl { font-size: 9.5px; color: var(--swe-text-2); margin-top: 1px; }
.swe-overview-legend { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
.swe-overview-legend-bottom {
flex-direction: row;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
width: 100%;
padding-top: 4px;
border-top: 1px solid rgba(139, 92, 246, 0.08);
}
.swe-overview-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--swe-text);
padding: 3px 4px;
border-radius: 5px;
transition: background 0.15s;
}
.swe-overview-legend-item:hover { background: rgba(139, 92, 246, 0.06); }
.swe-overview-legend-dot { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
.swe-overview-legend-lbl { color: var(--swe-text-2); }
.swe-overview-legend-bottom .swe-overview-legend-lbl { color: var(--swe-text); font-weight: 500; }
.swe-overview-legend-val { color: var(--swe-text-2); font-variant-numeric: tabular-nums; font-size: 10.5px; flex-shrink: 0; }
/* TOP 折扣排行 */
.swe-overview-ranks { display: flex; flex-direction: column; gap: 5px; }
.swe-overview-rank {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 7px;
text-decoration: none;
transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
.swe-overview-rank:hover { background: rgba(139, 92, 246, 0.08); border-color: rgba(139, 92, 246, 0.25); transform: translateX(2px); }
.swe-overview-rank-no {
width: 22px;
height: 22px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
color: var(--swe-text-2);
background: rgba(255, 255, 255, 0.05);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.swe-overview-rank-no.r1 { background: linear-gradient(135deg, #f59e0b, #d97706); color: #fff; box-shadow: 0 2px 6px rgba(245, 158, 11, 0.4); }
.swe-overview-rank-no.r2 { background: linear-gradient(135deg, #cbd5e1, #94a3b8); color: #1e293b; box-shadow: 0 2px 6px rgba(148, 163, 184, 0.35); }
.swe-overview-rank-no.r3 { background: linear-gradient(135deg, #d97706, #92400e); color: #fff; box-shadow: 0 2px 6px rgba(217, 119, 6, 0.35); }
.swe-overview-rank-mid { flex: 1; min-width: 0; }
.swe-overview-rank-name {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
color: #f1f5f9;
line-height: 1.3;
}
.swe-overview-rank-name > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swe-overview-rank-pct {
color: #fbbf24;
font-weight: 800;
font-size: 11px;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.swe-overview-rank-bar {
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.07);
margin-top: 4px;
overflow: hidden;
}
.swe-overview-rank-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 概览柱状图(高度提升,不再扁平) */
.swe-overview-bars { display: flex; flex-direction: column; gap: 7px; }
.swe-overview-bar-row {
display: flex;
align-items: center;
gap: 10px;
padding: 2px 0;
}
.swe-overview-bar-lbl {
width: 56px;
font-size: 11px;
color: var(--swe-text-2);
text-align: right;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.swe-overview-bar-track {
flex: 1;
height: 22px;
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
overflow: hidden;
min-width: 0;
position: relative;
}
.swe-overview-bar-fill {
height: 100%;
border-radius: 6px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 2px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.swe-overview-bar-val {
width: 36px;
font-size: 11px;
color: var(--swe-text);
font-variant-numeric: tabular-nums;
font-weight: 700;
text-align: right;
flex-shrink: 0;
}
/* v1.2.2 价值构成 + 状态分布 左右分栏布局 */
.swe-overview-duo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: stretch;
}
@media (max-width: 600px) {
.swe-overview-duo { grid-template-columns: 1fr; }
}
.swe-overview-duo .swe-overview-card {
padding: 10px 12px;
display: flex;
flex-direction: column;
}
.swe-overview-duo .swe-overview-value { gap: 8px; padding: 2px 0 0; flex: 1; }
.swe-overview-duo .swe-overview-donut-center-wrap svg { width: 120px; height: 120px; }
.swe-overview-duo .swe-overview-legend-bottom { gap: 8px; padding-top: 3px; }
.swe-overview-duo .swe-overview-legend-item { font-size: 10px; padding: 2px 3px; gap: 4px; }
.swe-overview-duo .swe-overview-legend-dot { width: 7px; height: 7px; }
/* 状态分布柱状图自动填充卡片高度,消除底部空白 */
.swe-overview-duo .swe-overview-bars { gap: 5px; flex: 1; justify-content: center; }
.swe-overview-duo .swe-overview-bar-row { gap: 6px; padding: 3px 0; }
.swe-overview-duo .swe-overview-bar-lbl { width: 48px; font-size: 10px; }
.swe-overview-duo .swe-overview-bar-track { height: 22px; }
.swe-overview-duo .swe-overview-bar-val { width: 28px; font-size: 10px; }
.swe-overview-duo .swe-overview-card-title { font-size: 11.5px; margin-bottom: 8px; }
.swe-overview-duo .swe-overview-donut-val { font-size: 12px; }
.swe-overview-duo .swe-overview-donut-lbl { font-size: 8.5px; }
/* v1.2.2 大作夙愿栏目 */
.swe-aaa-list { display: flex; flex-direction: column; gap: 5px; }
.swe-aaa-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 9px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 7px;
text-decoration: none;
transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
.swe-aaa-item:hover { background: rgba(139, 92, 246, 0.08); border-color: rgba(139, 92, 246, 0.25); transform: translateX(2px); }
.swe-aaa-poster {
width: 56px;
height: 26px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: rgba(255,255,255,0.04);
}
.swe-aaa-body { flex: 1; min-width: 0; }
.swe-aaa-name {
font-size: 11.5px;
font-weight: 600;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.swe-aaa-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
font-size: 10px;
color: var(--swe-text-2);
}
.swe-aaa-date { color: #60a5fa; font-weight: 500; }
.swe-aaa-price { color: #10b981; font-weight: 600; font-variant-numeric: tabular-nums; }
.swe-aaa-badge {
font-size: 9px;
font-weight: 700;
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
letter-spacing: 0.3px;
}
.swe-aaa-badge.soon { background: rgba(96, 165, 250, 0.15); color: #60a5fa; }
.swe-aaa-badge.owned { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.swe-aaa-badge.not-owned { background: rgba(244, 63, 94, 0.12); color: #f43f5e; }
.swe-aaa-empty { padding: 12px 0; text-align: center; font-size: 11px; color: var(--swe-text-2); }
/* ====== 图表区段 ====== */
.swe-section {
background: var(--swe-bg-card);
border: 1px solid rgba(139, 92, 246, 0.1);
border-radius: var(--swe-radius-sm);
padding: 10px 12px;
margin-top: 8px;
}
.swe-section:first-child { margin-top: 0; }
.swe-section-title {
font-size: 12px;
font-weight: 700;
color: var(--swe-text);
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
min-width: 0;
overflow: hidden;
}
.swe-section-title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.swe-section-title > .swe-overview-card-sub { flex-shrink: 0; }
.swe-section-title svg { width: 14px; height: 14px; color: var(--swe-accent); flex-shrink: 0; }
/* ====== 热力图(v1.0.4 按年绘制) ====== */
.swe-heatmap-block { display: flex; flex-direction: column; gap: 8px; }
.swe-hm-year-filter { flex-wrap: wrap; width: fit-content; max-width: 100%; }
.swe-heatmap-body { min-height: 0; }
.swe-heatmap-scroll { overflow-x: auto; overflow-y: hidden; border-radius: 6px; padding-bottom: 4px; }
.swe-heatmap-scroll::-webkit-scrollbar { height: 6px; }
.swe-heatmap-scroll::-webkit-scrollbar-track { background: transparent; }
.swe-heatmap-scroll::-webkit-scrollbar-thumb { background: rgba(139, 92, 246, 0.2); border-radius: 3px; }
.swe-heatmap-scroll::-webkit-scrollbar-thumb:hover { background: rgba(139, 92, 246, 0.35); }
.swe-heatmap-footer {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; flex-wrap: wrap; margin-top: 8px;
}
.swe-heatmap-summary {
display: flex; gap: 12px; flex-wrap: wrap;
font-size: 11px; color: var(--swe-text-2);
}
.swe-heatmap-summary b { color: var(--swe-text); font-weight: 700; font-variant-numeric: tabular-nums; }
.swe-heatmap-range { font-variant-numeric: tabular-nums; }
.swe-heatmap-legend {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
font-size: 10px; color: var(--swe-text-2);
}
.swe-heatmap-legend .swatch { width: 11px; height: 11px; border-radius: 2px; display: inline-block; }
/* ====== 时间线 ====== */
/* ====== 趋势页:让时间线撑满容器底部 ====== */
.swe-content-trends {
display: flex;
flex-direction: column;
gap: 8px;
}
.swe-content-trends > .swe-section { flex-shrink: 0; }
.swe-content-trends > .swe-section-flex {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
margin-top: 0;
}
.swe-content-trends > .swe-section-flex > .swe-timeline { flex: 1; min-height: 0; }
.swe-timeline { position: relative; padding-left: 16px; overflow-y: auto; width: 100%; box-sizing: border-box; flex: 1; }
/* ====== 趋势页:曲线图 ====== */
.swe-trend-section { display: flex; flex-direction: column; gap: 8px; }
.swe-trend-header {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; flex-wrap: wrap;
}
.swe-trend-header > .swe-section-title { margin-bottom: 0; }
.swe-trend-toggle {
display: flex; gap: 2px; background: rgba(255, 255, 255, 0.04);
border-radius: 8px; padding: 2px; flex-shrink: 0;
}
.swe-trend-toggle-btn {
padding: 4px 12px; font-size: 11px; font-weight: 600; border: none;
background: transparent; color: var(--swe-text-2); border-radius: 6px;
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.swe-trend-toggle-btn:hover { color: var(--swe-text); background: rgba(255, 255, 255, 0.06); }
.swe-trend-toggle-btn.active {
background: rgba(56, 189, 248, 0.15); color: #38bdf8;
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.3);
}
.swe-trend-chart-wrap {
position: relative; width: 100%;
}
.swe-trend-svg {
width: 100%; height: auto; display: block;
font-family: inherit;
}
.swe-trend-grid { stroke: rgba(148, 163, 184, 0.1); stroke-width: 1; stroke-dasharray: 3 3; }
.swe-trend-axis-y { fill: var(--swe-text-2); font-size: 10px; text-anchor: end; font-variant-numeric: tabular-nums; }
.swe-trend-axis-x { fill: var(--swe-text-2); font-size: 10px; font-variant-numeric: tabular-nums; }
.swe-trend-dot {
fill: #38bdf8; stroke: var(--swe-bg-solid); stroke-width: 2;
cursor: pointer; transition: r 0.15s;
}
.swe-trend-dot:hover { r: 6; fill: #7dd3fc; }
.swe-trend-peak-val { fill: #fbbf24; font-size: 11px; font-weight: 800; font-variant-numeric: tabular-nums; }
.swe-trend-tooltip {
position: absolute; pointer-events: none; z-index: 10;
background: rgba(13, 18, 35, 0.95); border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: 6px; padding: 4px 10px; font-size: 11px; color: #fff;
white-space: nowrap; opacity: 0; transition: opacity 0.15s;
transform: translate(-50%, -100%); margin-top: -8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.swe-trend-tooltip.visible { opacity: 1; }
.swe-trend-tooltip-val { color: #38bdf8; font-weight: 700; }
/* ====== 分析页:统一三段式布局 (标题 + 图表 + 留白) ====== */
/* v1.1.7: 移除 overflow-x: hidden——根据 CSS 规范,overflow-x:hidden 会让
overflow-y 从 visible 计算为 auto,使本元素成为潜在滚动容器,
在卡片间隙(gap)区域拦截滚轮事件不冒泡到 .swe-content,导致"非卡片处不可滚动"。
子元素已有 min-width:0 + overflow:hidden,足以防止水平溢出。 */
.swe-content-analytics {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
max-width: 100%;
}
/* v1.0.7: 修复内容超高时 flex 子项被压缩、溢出内容被后续卡片覆盖——禁止压缩,高度由内容撑开,容器自身滚动 */
.swe-content-analytics > .swe-section,
.swe-content-analytics > .swe-analytics-row {
flex-shrink: 0;
}
.swe-analytics-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
align-items: stretch;
min-width: 0;
max-width: 100%;
}
/* 同 row 卡片:等高对齐,flex 列布局让图表填充剩余空间 */
.swe-analytics-row > .swe-section {
display: flex;
flex-direction: column;
min-width: 0;
padding: 16px 18px 18px;
gap: 12px;
margin-top: 0;
overflow: hidden;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.swe-analytics-row > .swe-section:hover {
border-color: rgba(139, 92, 246, 0.25);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.1);
}
.swe-analytics-row > .swe-section > .swe-section-title {
flex-shrink: 0;
margin: 0 0 4px;
font-size: 13px;
color: #cbd5e1;
}
/* 图表主体:flex 1 填充剩余空间,使同行卡片等高对齐 */
.swe-analytics-row > .swe-section > .swe-az-donut-wrap {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.swe-analytics-row > .swe-section > .swe-az-priority {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-evenly;
width: 100%;
}
.swe-analytics-row > .swe-section > .swe-az-review {
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
/* 标签云 section:自然高度 */
.swe-content-analytics > .swe-section-flex {
display: flex;
flex-direction: column;
margin-top: 0;
padding: 16px 18px 18px;
gap: 12px;
min-height: 0;
}
.swe-content-analytics > .swe-section-flex > .swe-tag-grid {
flex: 0 0 auto;
}
@media (max-width: 520px) {
.swe-analytics-row { grid-template-columns: 1fr; }
}
/* ====== 分析页图表:统一视觉 (swe-az-* prefix) ====== */
/* Donut 居中 + 图例底部 */
.swe-az-donut-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
}
.swe-az-donut {
position: relative;
width: 124px;
height: 124px;
flex-shrink: 0;
}
.swe-az-donut svg { width: 100%; height: 100%; display: block; }
.swe-az-donut-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
}
.swe-az-donut-val {
font-size: 22px;
font-weight: 800;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
letter-spacing: -0.5px;
}
.swe-az-donut-lbl { font-size: 10.5px; color: var(--swe-text-2); margin-top: 2px; font-weight: 500; }
.swe-az-legend {
display: grid;
gap: 6px 16px;
width: 100%;
padding-top: 10px;
border-top: 1px solid rgba(139, 92, 246, 0.08);
}
.swe-az-legend-cols-1 { grid-template-columns: 1fr; }
.swe-az-legend-cols-2 { grid-template-columns: 1fr 1fr; }
.swe-az-legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 11.5px;
color: var(--swe-text);
min-width: 0;
}
.swe-az-legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
.swe-az-legend-lbl {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.swe-az-legend-val {
color: var(--swe-text-2);
font-variant-numeric: tabular-nums;
font-size: 10.5px;
flex-shrink: 0;
font-weight: 600;
}
/* 评分分布:5 列柱状(紧凑) */
.swe-az-review {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-rows: 1fr;
gap: 6px;
width: 100%;
min-height: 0;
}
.swe-az-review-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
min-width: 0;
min-height: 0;
}
.swe-az-review-val {
font-size: 13px;
font-weight: 800;
color: #fff;
font-variant-numeric: tabular-nums;
line-height: 1;
flex-shrink: 0;
}
.swe-az-review-track {
width: 100%;
max-width: 36px;
flex: 1 1 0;
min-height: 50px;
background: rgba(255, 255, 255, 0.04);
border-radius: 5px 5px 0 0;
overflow: hidden;
display: flex;
align-items: flex-end;
justify-content: center;
}
.swe-az-review-fill {
width: 100%;
border-radius: 4px 4px 0 0;
min-height: 2px;
transition: height 0.5s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.swe-az-review-lbl {
font-size: 10.5px;
color: var(--swe-text-2);
font-variant-numeric: tabular-nums;
font-weight: 500;
flex-shrink: 0;
}
/* 优先级分布:横向柱状 */
.swe-az-priority {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
justify-content: center;
}
.swe-az-priority-row {
display: flex;
align-items: center;
gap: 10px;
}
.swe-az-priority-lbl {
width: 60px;
font-size: 12px;
color: var(--swe-text);
font-weight: 600;
text-align: right;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.swe-az-priority-track {
flex: 1;
height: 16px;
background: rgba(255, 255, 255, 0.04);
border-radius: 5px;
overflow: hidden;
min-width: 0;
}
.swe-az-priority-fill {
height: 100%;
border-radius: 5px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 2px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.swe-az-priority-val {
width: 40px;
text-align: right;
font-size: 12px;
color: #fff;
font-weight: 700;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* v1.1.1: 我的类别分布——卡片式(色点+名称+数量+百分比+mini-stats+进度条) */
.swe-cat-list { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.swe-cat-card { padding: 8px 10px; border-radius: 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); min-width: 0; overflow: hidden; }
.swe-cat-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; min-width: 0; }
.swe-cat-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cat-color, #8b5cf6); flex-shrink: 0; }
.swe-cat-name { flex: 1; font-size: 12px; font-weight: 600; color: var(--swe-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.swe-cat-count { font-size: 13px; font-weight: 700; color: var(--swe-accent); flex-shrink: 0; }
.swe-cat-pct { font-size: 10px; color: var(--swe-text-2); font-weight: 500; margin-left: 3px; }
.swe-cat-stats { display: flex; gap: 8px; margin-bottom: 4px; flex-wrap: wrap; min-width: 0; }
.swe-cat-stat { font-size: 10px; color: var(--swe-text-2); display: flex; align-items: center; gap: 2px; white-space: nowrap; flex-shrink: 0; }
.swe-cat-stat svg { width: 11px; height: 11px; opacity: 0.7; }
.swe-cat-bar-track { height: 3px; border-radius: 2px; background: rgba(255,255,255,0.06); overflow: hidden; }
.swe-cat-bar-fill { height: 100%; border-radius: 2px; transition: width 0.3s ease; }
/* v1.1.1: 愿望单类别视图——左右分栏 */
.swe-cat-view { display: flex; gap: 12px; min-height: 400px; }
.swe-cat-sidebar { width: 200px; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; max-height: 70vh; }
.swe-cat-side-item { display: flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; color: var(--swe-text); border: 1px solid transparent; transition: all 0.15s; }
.swe-cat-side-item:hover { background: rgba(255,255,255,0.05); }
.swe-cat-side-item.active { background: rgba(139,92,246,0.12); border-color: rgba(139,92,246,0.3); color: var(--swe-accent); font-weight: 600; }
.swe-cat-side-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.swe-cat-side-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swe-cat-side-count { font-size: 11px; color: var(--swe-text-2); flex-shrink: 0; }
.swe-cat-main { flex: 1; min-width: 0; }
.swe-cat-main-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.swe-cat-main-title { font-size: 14px; font-weight: 700; color: var(--swe-text); display: flex; align-items: center; gap: 6px; }
.swe-cat-main-stats { font-size: 11px; color: var(--swe-text-2); display: flex; gap: 12px; }
/* v1.1.2: 类别分布全宽两栏(左柱状图 + 右环形图) */
/* v1.1.3: 修复柱状图宽度超出(minmax(0, ...) 防止内容撑开网格) + 左右分栏顶部对齐(align-items: flex-start) + 右侧图例强制单列避免再次撑开 */
/* v1.1.6: 左右栏比例改为 1:1 与上方 swe-analytics-row 对齐;gap 统一 14px;
右侧 donut 增大至 140px 填充空间;图例双列适配更宽右栏 */
.swe-cat-dual-section { width: 100%; box-sizing: border-box; margin-top: 0; padding: 16px 18px 18px; border-radius: var(--swe-radius); background: var(--swe-card-bg); border: 1px solid var(--swe-border); overflow: hidden; }
.swe-cat-dual-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; align-items: stretch; min-width: 0; }
.swe-cat-dual-left { min-width: 0; overflow: hidden; display: flex; flex-direction: column; justify-content: center; }
.swe-cat-dual-right { min-width: 0; overflow: hidden; display: flex; align-items: center; justify-content: center; padding-top: 0; }
.swe-cat-dual-right .swe-az-donut-wrap { max-width: 100%; overflow: hidden; height: 100%; justify-content: center; gap: 12px; }
.swe-cat-dual-right .swe-az-donut { width: 140px; height: 140px; }
.swe-cat-dual-right .swe-az-donut-val { font-size: 24px; }
.swe-cat-dual-right .swe-az-legend { max-width: 100%; overflow: hidden; }
.swe-cat-dual-right .swe-az-legend-lbl { max-width: 100%; }
.swe-cat-dual-right .swe-az-donut svg { display: block; }
@media (max-width: 520px) {
.swe-cat-dual-grid { grid-template-columns: 1fr; }
.swe-cat-dual-left { justify-content: flex-start; }
}
/* v1.0.5: 待上市最近发售提示 */
.swe-az-upcoming-nearest {
display: flex; align-items: center; gap: 6px;
margin-top: 2px; padding: 7px 10px;
font-size: 11px; color: var(--swe-text-2);
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.swe-az-upcoming-nearest svg { width: 12px; height: 12px; color: #10b981; flex-shrink: 0; }
.swe-az-upcoming-nearest span { color: var(--swe-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
/* v1.1.3: 待上市游戏——全宽双栏(左柱状图 + 右 Top 5 列表) */
.swe-upcoming-dual-section { width: 100%; box-sizing: border-box; margin-top: 0; padding: 16px 18px 18px; border-radius: var(--swe-radius); background: var(--swe-card-bg); border: 1px solid var(--swe-border); overflow: hidden; }
.swe-upcoming-dual-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; }
.swe-upcoming-dual-left { min-width: 0; overflow: hidden; }
.swe-upcoming-dual-right { min-width: 0; overflow: hidden; display: flex; flex-direction: column; gap: 8px; }
.swe-up-toplist-title { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; color: var(--swe-text); padding-bottom: 4px; border-bottom: 1px solid rgba(139, 92, 246, 0.12); }
.swe-up-toplist-title svg { width: 13px; height: 13px; color: #f97316; flex-shrink: 0; }
.swe-up-toplist { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.swe-up-topitem { display: flex; align-items: center; gap: 8px; padding: 5px 7px; border-radius: 6px; background: rgba(255,255,255,0.025); border: 1px solid transparent; text-decoration: none; color: var(--swe-text); min-width: 0; transition: all 0.18s ease; }
.swe-up-topitem:hover { background: rgba(139, 92, 246, 0.10); border-color: rgba(139, 92, 246, 0.25); transform: translateX(2px); }
.swe-up-rank { font-size: 10px; font-weight: 800; color: var(--swe-text-2); width: 14px; text-align: center; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.swe-up-cover { width: 60px; height: 28px; border-radius: 3px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.05); }
.swe-up-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.swe-up-name { font-size: 11.5px; font-weight: 600; color: var(--swe-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.swe-up-meta { display: flex; align-items: center; gap: 6px; min-width: 0; }
.swe-up-date { font-size: 9.5px; color: var(--swe-text-2); font-variant-numeric: tabular-nums; flex-shrink: 0; }
.swe-up-countdown { font-size: 9.5px; font-weight: 700; padding: 1px 5px; border-radius: 3px; margin-left: auto; flex-shrink: 0; font-variant-numeric: tabular-nums; letter-spacing: 0.2px; white-space: nowrap; }
.swe-up-countdown.hot { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
.swe-up-countdown.warm { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
.swe-up-countdown.cool { background: rgba(99, 102, 241, 0.15); color: #a5b4fc; border: 1px solid rgba(99, 102, 241, 0.3); }
.swe-up-countdown.today { background: rgba(16, 185, 129, 0.20); color: #4ade80; border: 1px solid rgba(16, 185, 129, 0.4); animation: swe-pulse-today 2s ease-in-out infinite; }
.swe-up-countdown.tba { background: rgba(100, 116, 139, 0.20); color: #94a3b8; border: 1px solid rgba(100, 116, 139, 0.3); }
@keyframes swe-pulse-today { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
@media (max-width: 520px) {
.swe-upcoming-dual-grid { grid-template-columns: 1fr; }
}
/* ====== v1.0.6 设置浮窗 ====== */
.swe-settings-overlay {
position: fixed; inset: 0; z-index: 1000010;
background: rgba(5, 8, 18, 0.72); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center;
opacity: 0; visibility: hidden; transition: opacity 0.2s ease, visibility 0.2s;
}
.swe-settings-overlay.swe-show { opacity: 1; visibility: visible; }
.swe-settings-panel {
width: 420px; max-width: calc(100vw - 40px); max-height: 82vh;
display: flex; flex-direction: column;
background: var(--swe-bg-solid); border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: var(--swe-radius); box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
transform: translateY(8px); transition: transform 0.2s ease;
}
.swe-settings-overlay.swe-show .swe-settings-panel { transform: translateY(0); }
.swe-settings-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid rgba(139, 92, 246, 0.15);
font-size: 14px; font-weight: 700; color: var(--swe-text);
}
.swe-settings-header svg { width: 16px; height: 16px; color: var(--swe-accent); vertical-align: -3px; margin-right: 6px; }
.swe-settings-close {
width: 28px; height: 28px; border: none; border-radius: 6px; cursor: pointer;
background: rgba(255, 255, 255, 0.05); color: var(--swe-text-2);
display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s;
}
.swe-settings-close:hover { background: rgba(244, 63, 94, 0.15); color: #fda4af; }
.swe-settings-close svg { width: 14px; height: 14px; margin: 0; color: inherit; }
.swe-settings-body { padding: 14px 16px 16px; overflow-y: auto; }
.swe-settings-section-title {
font-size: 12px; font-weight: 700; color: var(--swe-text);
display: flex; align-items: center; gap: 6px; margin-bottom: 8px;
}
.swe-settings-section-title svg { width: 14px; height: 14px; color: var(--swe-accent); }
.swe-settings-tip {
font-size: 11px; color: var(--swe-text-2); line-height: 1.6;
background: rgba(139, 92, 246, 0.06); border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 6px; padding: 8px 10px; margin-bottom: 12px;
}
.swe-form-group { margin-bottom: 12px; }
.swe-form-group label { display: block; font-size: 11px; font-weight: 600; color: var(--swe-text-2); margin-bottom: 4px; }
.swe-form-input {
width: 100%; box-sizing: border-box; padding: 8px 10px;
font-size: 12px; font-family: inherit; color: var(--swe-text);
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 6px; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
}
.swe-form-input:focus { border-color: var(--swe-border-focus); box-shadow: 0 0 0 2px rgba(102, 192, 244, 0.15); }
.swe-form-hint { font-size: 10.5px; color: var(--swe-text-2); margin-top: 4px; line-height: 1.5; }
.swe-settings-footer { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.swe-settings-status { font-size: 12px; color: #34d399; font-weight: 600; }
/* ====== v1.0.6 AI 犀利点评 ====== */
.swe-ai-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.swe-ai-head > .swe-section-title { margin-bottom: 0; }
.swe-ai-body { margin-top: 10px; }
.swe-ai-empty {
display: flex; flex-direction: column; align-items: center; text-align: center;
padding: 22px 14px; gap: 6px;
}
.swe-ai-empty-icon { width: 34px; height: 34px; color: var(--swe-accent); opacity: 0.85; }
.swe-ai-empty-icon svg { width: 100%; height: 100%; }
.swe-ai-empty-text { font-size: 13px; font-weight: 700; color: var(--swe-text); }
.swe-ai-empty-sub { font-size: 11px; color: var(--swe-text-2); line-height: 1.6; max-width: 420px; }
.swe-ai-warn {
margin-top: 8px; font-size: 11px; color: #fbbf24;
background: rgba(251, 191, 36, 0.08); border: 1px solid rgba(251, 191, 36, 0.25);
border-radius: 6px; padding: 6px 10px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center;
}
.swe-ai-error { color: #fda4af; }
.swe-ai-spin { animation: swe-spin 0.9s linear infinite; transform-origin: 50% 50%; }
.swe-ai-oneliner {
position: relative; padding: 12px 14px; margin-bottom: 10px;
font-size: 13px; font-weight: 700; color: #e9d5ff; line-height: 1.6;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.14), rgba(59, 130, 246, 0.08));
border: 1px solid rgba(139, 92, 246, 0.3); border-radius: 8px;
display: flex; gap: 8px; align-items: flex-start;
}
.swe-ai-oneliner svg { width: 15px; height: 15px; color: #c4b5fd; flex-shrink: 0; margin-top: 2px; }
.swe-ai-sec { display: flex; gap: 10px; margin-bottom: 8px; }
.swe-ai-sec-bar { width: 3px; border-radius: 2px; flex-shrink: 0; }
.swe-ai-sec-body { flex: 1; min-width: 0; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(139, 92, 246, 0.1); border-radius: 8px; padding: 8px 12px; }
.swe-ai-sec-title { font-size: 12px; font-weight: 700; margin-bottom: 4px; display: flex; align-items: center; gap: 5px; }
.swe-ai-sec-title svg { width: 12px; height: 12px; }
.swe-ai-sec-content { font-size: 12px; color: var(--swe-text); line-height: 1.7; white-space: pre-wrap; word-break: break-word; }
.swe-ai-truncated {
font-size: 10.5px; color: #fbbf24; margin-bottom: 8px;
background: rgba(251, 191, 36, 0.08); border: 1px dashed rgba(251, 191, 36, 0.3);
border-radius: 6px; padding: 5px 8px;
}
/* 标签云:grid 自适应,行高固定(不拉伸撑满) */
.swe-tag-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
grid-auto-rows: 64px;
grid-auto-flow: row dense;
gap: 8px;
align-content: start;
padding: 2px;
}
.swe-tag-chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 8px 12px;
border-radius: 9px;
border: 1px solid;
font-weight: 600;
line-height: 1.2;
transition: transform 0.15s, background 0.15s, box-shadow 0.15s;
min-width: 0;
overflow: hidden;
}
.swe-tag-chip:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.swe-tag-chip-name {
flex: 1;
min-width: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
font-weight: 700;
line-height: 1.2;
}
.swe-tag-chip-count {
font-size: 10px;
font-weight: 800;
padding: 2px 7px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.12);
color: inherit;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.swe-timeline::before { content: ''; position: absolute; left: 4px; top: 6px; bottom: 6px; width: 2px; background: linear-gradient(180deg, rgba(139,92,246,0.4), rgba(139,92,246,0.05)); border-radius: 1px; }
.swe-tl-group { margin-bottom: 10px; width: 100%; }
.swe-tl-group:last-child { margin-bottom: 0; }
.swe-tl-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; position: relative; }
.swe-tl-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b5cf6; border: 2px solid var(--swe-bg-card); position: absolute; left: -16px; top: 50%; transform: translateY(-50%); box-shadow: 0 0 6px rgba(139,92,246,0.5); z-index: 1; }
.swe-tl-date { font-size: 12px; font-weight: 700; color: var(--swe-text); font-variant-numeric: tabular-nums; }
.swe-tl-rel { font-size: 10px; color: #8b5cf6; background: rgba(139,92,246,0.12); padding: 1px 6px; border-radius: 8px; }
.swe-tl-count { font-size: 10px; color: var(--swe-text-2); margin-left: auto; background: rgba(255,255,255,0.06); padding: 1px 6px; border-radius: 8px; font-variant-numeric: tabular-nums; }
.swe-tl-items { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.swe-tl-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; background: rgba(255,255,255,0.03); border: 1px solid transparent; transition: all 0.2s ease; text-decoration: none; width: 100%; box-sizing: border-box; }
.swe-tl-item:hover { background: rgba(139,92,246,0.08); border-color: rgba(139,92,246,0.2); transform: translateX(2px); }
.swe-tl-item .swe-list-img { width: 70px; height: 33px; border-radius: 3px; flex-shrink: 0; object-fit: cover; }
.swe-tl-info { flex: 1; min-width: 0; }
.swe-tl-name { font-size: 11px; font-weight: 600; color: var(--swe-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.swe-tl-meta { display: flex; align-items: center; gap: 4px; margin-top: 2px; }
.swe-tl-badge { font-size: 9px; font-weight: 700; padding: 0 4px; border-radius: 3px; }
.swe-tl-badge.discount { background: rgba(74,222,128,0.15); color: #4ade80; }
.swe-tl-badge.free { background: rgba(99,102,241,0.15); color: #818cf8; }
.swe-tl-id { font-size: 9px; color: var(--swe-text-2); margin-left: auto; font-variant-numeric: tabular-nums; }
/* ====== 游戏卡片网格 ====== */
.swe-card-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
@media (max-width: 520px) { .swe-card-grid { grid-template-columns: repeat(2, 1fr); } }
.swe-game-card {
display: block;
text-decoration: none;
color: inherit;
background: var(--swe-bg-card);
border: 1px solid rgba(139, 92, 246, 0.08);
border-radius: var(--swe-radius-sm);
overflow: hidden;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.swe-game-card:hover { border-color: rgba(139, 92, 246, 0.3); transform: translateY(-2px); box-shadow: 0 4px 16px rgba(139, 92, 246, 0.15); }
.swe-game-card:focus-visible { outline: 2px solid var(--swe-border-focus); outline-offset: 2px; }
.swe-card-img-wrap { position: relative; width: 100%; aspect-ratio: 2/3; overflow: hidden; background: rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: center; }
.swe-card-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.swe-card-img.swe-img-failed { object-fit: contain; padding: 20%; opacity: 0.55; }
.swe-card-body { padding: 8px 10px; }
.swe-card-name { font-size: 12px; font-weight: 600; color: #e0e0e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.swe-card-id-inline { font-size: 10px; color: var(--swe-text-2); margin-left: 5px; font-weight: 400; flex-shrink: 0; }
.swe-card-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-top: 4px; }
.swe-card-badge { display: inline-block; font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 3px; }
.swe-card-badge.discount { background: rgba(74,222,128,0.15); color: #4ade80; }
.swe-card-badge.free { background: rgba(99,102,241,0.15); color: #818cf8; }
.swe-card-badge.ea { background: rgba(251,146,60,0.15); color: #fb923c; }
.swe-card-badge.soon { background: rgba(96,165,250,0.15); color: #60a5fa; }
.swe-card-price { font-size: 12px; font-weight: 700; color: #aee435; font-variant-numeric: tabular-nums; }
.swe-card-orig { font-size: 10px; color: var(--swe-text-2); text-decoration: line-through; margin-left: 4px; }
.swe-card-rank {
display: inline-flex;
align-items: center;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
background: rgba(139, 92, 246, 0.12);
color: #c4b5fd;
flex-shrink: 0;
}
.swe-card-rank.rank-1 { background: rgba(255,215,0,0.16); color: #ffd700; }
.swe-card-rank.rank-2 { background: rgba(192,192,192,0.16); color: #c0c0c0; }
.swe-card-rank.rank-3 { background: rgba(205,127,50,0.2); color: #e09a5f; }
.swe-card-added { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; color: var(--swe-text-2); white-space: nowrap; }
.swe-card-added svg { width: 9px; height: 9px; opacity: 0.7; }
/* ====== 横板封面视图(3列,184/69 宽高比横版胶囊图) ====== */
.swe-cover-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
@media (max-width: 520px) { .swe-cover-grid { grid-template-columns: repeat(2, 1fr); } }
.swe-cover-card {
display: flex; flex-direction: column;
text-decoration: none; color: inherit;
background: var(--swe-bg-card);
border: 1px solid rgba(139, 92, 246, 0.08);
border-radius: var(--swe-radius-sm);
overflow: hidden;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.swe-cover-card:hover { border-color: rgba(139, 92, 246, 0.3); transform: translateY(-2px); box-shadow: 0 4px 16px rgba(139, 92, 246, 0.15); }
.swe-cover-card:focus-visible { outline: 2px solid var(--swe-border-focus); outline-offset: 2px; }
.swe-cover-img-wrap { position: relative; width: 100%; aspect-ratio: 184/69; overflow: hidden; background: #0f172a; display: flex; align-items: center; justify-content: center; }
.swe-cover-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.swe-cover-img.swe-img-failed { object-fit: contain; padding: 10%; opacity: 0.55; }
.swe-cover-badge-wrap { position: absolute; top: 4px; left: 4px; display: flex; gap: 3px; }
.swe-cover-rank-badge { position: absolute; top: 4px; right: 4px; font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 3px; background: rgba(0,0,0,0.65); color: #c4b5fd; }
.swe-cover-rank-badge.rank-1 { background: rgba(255,215,0,0.85); color: #1b2838; }
.swe-cover-rank-badge.rank-2 { background: rgba(192,192,192,0.85); color: #1b2838; }
.swe-cover-rank-badge.rank-3 { background: rgba(205,127,50,0.85); color: #1b2838; }
.swe-cover-body { padding: 7px 9px; display: flex; flex-direction: column; gap: 4px; }
.swe-cover-name { font-size: 12px; font-weight: 600; color: #e0e0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swe-cover-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.swe-cover-price { font-size: 12px; font-weight: 700; color: #aee435; font-variant-numeric: tabular-nums; }
.swe-cover-orig { font-size: 10px; color: var(--swe-text-2); text-decoration: line-through; margin-left: 4px; }
.swe-cover-added { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; color: var(--swe-text-2); white-space: nowrap; margin-left: auto; }
.swe-cover-added svg { width: 9px; height: 9px; opacity: 0.7; }
/* ====== 列表视图 ====== */
.swe-list-view { display: flex; flex-direction: column; gap: 4px; }
.swe-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: rgba(20, 27, 50, 0.5);
border: 1px solid rgba(139, 92, 246, 0.06);
border-radius: var(--swe-radius-sm);
text-decoration: none;
color: inherit;
transition: border-color 0.2s, background 0.2s;
cursor: pointer;
}
.swe-list-item:hover { background: rgba(40, 55, 90, 0.5); border-color: rgba(139, 92, 246, 0.2); }
.swe-list-item:focus-visible { outline: 2px solid var(--swe-border-focus); outline-offset: 2px; }
.swe-list-img { width: 92px; height: 43px; border-radius: 4px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.05); }
.swe-list-img.swe-img-failed { object-fit: contain; padding: 8px 14px; opacity: 0.55; }
.swe-list-info { flex: 1; min-width: 0; }
.swe-list-name { font-size: 13px; font-weight: 600; color: #e0e0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swe-list-meta { font-size: 11px; color: var(--swe-text-2); display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
.swe-list-price { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
/* ====== 分页 ====== */
.swe-pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px; flex-shrink: 0; }
.swe-page-info { font-size: 11px; color: var(--swe-text-2); font-variant-numeric: tabular-nums; }
/* v1.1.0:工具栏右侧快速分页 */
.swe-quick-pagination {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
flex-wrap: nowrap;
}
.swe-quick-page {
min-width: 28px;
padding: 2px 6px !important;
font-size: 11px !important;
font-variant-numeric: tabular-nums;
text-align: center;
}
.swe-quick-nav { padding: 2px 4px !important; }
.swe-quick-ellipsis {
font-size: 11px;
color: var(--swe-text-2);
padding: 0 2px;
user-select: none;
}
.swe-quick-info {
font-size: 11px;
color: var(--swe-text-2);
font-variant-numeric: tabular-nums;
margin-left: 4px;
white-space: nowrap;
}
/* ====== 空状态 ====== */
.swe-empty { text-align: center; color: var(--swe-text-2); padding: 40px 20px; font-size: 13px; }
/* ====== Toast ====== */
.swe-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(80px);
padding: 10px 20px;
border-radius: var(--swe-radius-sm);
font-size: 13px;
font-weight: 600;
z-index: 1000005;
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: none;
max-width: 90vw;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.swe-toast--visible { opacity: 1; transform: translateX(-50%) translateY(0); }
.swe-toast--info { background: rgba(59, 130, 246, 0.9); color: #fff; }
.swe-toast--success { background: rgba(34, 197, 94, 0.9); color: #fff; }
.swe-toast--error { background: rgba(239, 68, 68, 0.9); color: #fff; }
.swe-toast--warning { background: rgba(245, 158, 11, 0.9); color: #fff; }
/* ====== 进度条 ====== */
.swe-progress-overlay {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000004;
background: var(--swe-bg-solid);
padding: 10px 16px;
border-top: 1px solid rgba(139, 92, 246, 0.15);
display: none;
}
.swe-progress-overlay.show { display: block; }
.swe-progress-text { font-size: 11px; color: var(--swe-text-2); margin-bottom: 4px; }
.swe-progress-bar { height: 4px; background: rgba(255,255,255,0.06); border-radius: 2px; overflow: hidden; }
.swe-progress-fill { height: 100%; background: linear-gradient(90deg, #8b5cf6, #3b82f6, #06b6d4); border-radius: 2px; transition: width 0.3s ease; }
/* 尊重用户系统设置(降低动效) */
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
/* ====== 货币徽章 ====== */
.swe-currency-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 10px;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.15);
font-size: 11px;
font-weight: 600;
color: var(--swe-text);
white-space: nowrap;
}
/* ====== 两栏布局 ====== */
.swe-dual { display: flex; flex-direction: row; gap: 12px; min-height: 0; flex: 1; }
.swe-dual-side { width: 300px; flex-shrink: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 0; min-height: 0; padding-right: 4px; }
.swe-dual-main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.swe-dual-main .swe-content { flex: 1; overflow-y: auto; min-height: 0; padding: 12px; }
@media (max-width: 700px) {
.swe-dual { flex-direction: column; overflow-y: auto; }
.swe-dual-side { width: 100%; overflow: visible; }
}
/* ====== 下拉选择框 ====== */
.swe-select {
padding: 6px 10px;
background: rgba(15, 19, 42, 0.6);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: var(--swe-radius-sm);
color: var(--swe-text-bright);
font-size: 12px;
outline: none;
cursor: pointer;
font-family: inherit;
transition: border-color 0.2s;
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'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 28px;
appearance: none;
-webkit-appearance: none;
}
.swe-select:focus { border-color: rgba(139, 92, 246, 0.4); }
.swe-select option { background: var(--swe-bg-solid); color: var(--swe-text); }
/* ====== v1.1.0 价格历史标记 ====== */
.swe-ph-badge {
display: inline-flex; align-items: center; gap: 3px;
font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 3px;
font-variant-numeric: tabular-nums; white-space: nowrap; line-height: 1.4;
}
.swe-ph-badge svg { width: 9px; height: 9px; opacity: 0.85; }
.swe-ph-badge.ph-low { background: rgba(74,222,128,0.18); color: #4ade80; }
.swe-ph-badge.ph-near { background: rgba(251,191,36,0.18); color: #fbbf24; }
.swe-ph-badge.ph-high { background: rgba(248,113,113,0.16); color: #f87171; }
.swe-ph-badge.ph-loading { background: rgba(148,163,184,0.12); color: var(--swe-text-2); font-weight: 600; }
.swe-ph-badge.ph-none { background: rgba(148,163,184,0.1); color: var(--swe-text-2); font-weight: 600; }
.swe-ph-dot { display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0.7; }
/* ====== v1.1.0 批量管理 ====== */
.swe-batch-toolbar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 8px 10px; margin-bottom: 6px;
background: linear-gradient(135deg, rgba(139,92,246,0.12), rgba(59,130,246,0.08));
border: 1px solid rgba(139,92,246,0.25); border-radius: var(--swe-radius-sm);
}
.swe-batch-toolbar .swe-batch-count { font-size: 12px; font-weight: 700; color: #c4b5fd; font-variant-numeric: tabular-nums; }
.swe-batch-toolbar .swe-batch-spacer { flex: 1; }
.swe-batch-toolbar .swe-btn-danger { background: rgba(248,113,113,0.16); border-color: rgba(248,113,113,0.35); color: #f87171; }
.swe-batch-toolbar .swe-btn-danger:hover { background: rgba(248,113,113,0.28); color: #fca5a5; }
.swe-batch-toolbar .swe-btn-danger:disabled { opacity: 0.4; }
/* 批量模式下的游戏项:整项可点选 */
.swe-batch-on { cursor: default !important; }
.swe-batch-on:hover { transform: none !important; }
.swe-game-card.swe-batch-on, .swe-cover-card.swe-batch-on, .swe-list-item.swe-batch-on { position: relative; }
.swe-game-card.swe-batch-selected, .swe-cover-card.swe-batch-selected, .swe-list-item.swe-batch-selected {
border-color: rgba(139,92,246,0.7) !important;
box-shadow: 0 0 0 1px rgba(139,92,246,0.5), 0 4px 16px rgba(139,92,246,0.2) !important;
}
/* 批量复选框指示器(绝对定位在卡片角落) */
.swe-batch-check {
position: absolute; z-index: 3;
width: 20px; height: 20px; border-radius: 5px;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.7); border: 1.5px solid rgba(255,255,255,0.5);
color: #fff; transition: all 0.15s ease; pointer-events: none;
}
.swe-batch-check svg { width: 13px; height: 13px; opacity: 0; transition: opacity 0.15s ease; }
.swe-game-card .swe-batch-check { top: 6px; left: 6px; }
.swe-cover-card .swe-batch-check { top: 6px; left: 6px; }
.swe-list-item .swe-batch-check { position: static; width: 18px; height: 18px; margin-right: 2px; flex-shrink: 0; }
.swe-batch-selected .swe-batch-check { background: linear-gradient(135deg,#8b5cf6,#3b82f6); border-color: transparent; }
.swe-batch-selected .swe-batch-check svg { opacity: 1; }
/* 批量移除确认面板 */
.swe-batch-confirm-list {
max-height: 240px; overflow-y: auto; margin: 12px 0;
padding: 8px; background: rgba(0,0,0,0.25); border-radius: var(--swe-radius-sm);
display: flex; flex-direction: column; gap: 4px;
font-size: 12px; color: var(--swe-text);
}
.swe-batch-confirm-item { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 4px; }
.swe-batch-confirm-item:nth-child(odd) { background: rgba(255,255,255,0.03); }
.swe-batch-confirm-item .swe-bci-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swe-batch-confirm-item .swe-bci-id { font-size: 10px; color: var(--swe-text-2); font-variant-numeric: tabular-nums; }
.swe-batch-progress { font-size: 11px; color: var(--swe-text-2); margin-top: 8px; font-variant-numeric: tabular-nums; }
/* ====== v1.2.0 史低价格分析 ====== */
.swe-histlow-section { margin-top: 4px; }
.swe-histlow-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 18px; border-radius: var(--swe-radius-sm);
border: 1px solid var(--swe-border); background: var(--swe-bg-card); color: var(--swe-text-bright); font-size: 13px; font-weight: 600;
cursor: pointer; transition: all 0.2s; }
.swe-histlow-btn.primary { background: linear-gradient(135deg, var(--swe-accent), var(--swe-accent-blue)); border-color: transparent; }
.swe-histlow-btn.primary:hover { box-shadow: 0 2px 12px rgba(139,92,246,0.4); transform: translateY(-1px); }
.swe-histlow-btn.secondary { background: var(--swe-bg-hover); }
.swe-histlow-btn.secondary:hover { border-color: var(--swe-border-hover); }
.swe-histlow-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.swe-histlow-btn svg { width: 15px; height: 15px; }
.swe-histlow-progress { display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 13px; color: var(--swe-accent-cyan); }
.swe-histlow-progress svg { width: 15px; height: 15px; animation: swe-spin 1s linear infinite; transform-origin: 50% 50%; }
.swe-histlow-kpis { display: flex; gap: 8px; margin: 12px 0; flex-wrap: nowrap; overflow-x: auto; }
.swe-histlow-kpi { flex: 1 1 0; min-width: 0; padding: 10px 12px; border-radius: var(--swe-radius-sm); background: var(--swe-bg-card); border: 1px solid var(--swe-border); text-align: center; }
.swe-histlow-kpi.green { border-color: rgba(74,222,128,0.3); background: rgba(74,222,128,0.06); }
.swe-histlow-kpi.yellow { border-color: rgba(251,191,36,0.3); background: rgba(251,191,36,0.06); }
.swe-histlow-kpi.red { border-color: rgba(248,113,113,0.3); background: rgba(248,113,113,0.06); }
.swe-histlow-kpi-icon { display: flex; justify-content: center; margin-bottom: 4px; }
.swe-histlow-kpi-icon svg { width: 16px; height: 16px; color: var(--swe-text-2); }
.swe-histlow-kpi.green .swe-histlow-kpi-icon svg { color: var(--swe-green); }
.swe-histlow-kpi.yellow .swe-histlow-kpi-icon svg { color: var(--swe-amber); }
.swe-histlow-kpi.red .swe-histlow-kpi-icon svg { color: var(--swe-red); }
.swe-histlow-kpi-val { font-size: 20px; font-weight: 700; color: var(--swe-text-bright); font-variant-numeric: tabular-nums; }
.swe-histlow-kpi.green .swe-histlow-kpi-val { color: var(--swe-green); }
.swe-histlow-kpi.yellow .swe-histlow-kpi-val { color: var(--swe-amber); }
.swe-histlow-kpi.red .swe-histlow-kpi-val { color: var(--swe-red); }
.swe-histlow-kpi-lbl { font-size: 10px; color: var(--swe-text-2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.swe-histlow-list { max-height: 400px; overflow-y: auto; border-radius: var(--swe-radius-sm); border: 1px solid var(--swe-border); }
.swe-histlow-row { display: grid; grid-template-columns: 1fr auto; gap: 4px 8px; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,0.04); align-items: center; transition: background 0.15s; }
.swe-histlow-row:hover { background: var(--swe-bg-hover); }
.swe-histlow-row:last-child { border-bottom: none; }
.swe-histlow-row.at-low { border-left: 3px solid var(--swe-green); }
.swe-histlow-row.near-low { border-left: 3px solid var(--swe-amber); }
.swe-histlow-row.above-low { border-left: 3px solid var(--swe-red); }
.swe-histlow-row.no-data { border-left: 3px solid var(--swe-text-2); opacity: 0.6; }
.swe-histlow-name { font-size: 12px; color: var(--swe-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; grid-column: 1; }
.swe-histlow-prices { display: flex; gap: 8px; align-items: center; grid-column: 2; font-variant-numeric: tabular-nums; }
.swe-histlow-cur { font-size: 12px; color: var(--swe-text-bright); font-weight: 600; }
.swe-histlow-low { font-size: 11px; color: var(--swe-green); }
.swe-histlow-dist { font-size: 10px; color: var(--swe-text-2); min-width: 38px; text-align: right; }
.swe-histlow-meta { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; grid-column: 1 / -1; }
.swe-histlow-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; font-weight: 600; text-transform: uppercase; }
.swe-histlow-row.at-low .swe-histlow-badge { background: rgba(74,222,128,0.15); color: var(--swe-green); }
.swe-histlow-row.near-low .swe-histlow-badge { background: rgba(251,191,36,0.15); color: var(--swe-amber); }
.swe-histlow-row.above-low .swe-histlow-badge { background: rgba(248,113,113,0.15); color: var(--swe-red); }
.swe-histlow-row.no-data .swe-histlow-badge { background: rgba(148,163,184,0.15); color: var(--swe-text-2); }
.swe-histlow-cut { font-size: 10px; color: var(--swe-accent-cyan); font-weight: 600; }
.swe-histlow-maxcut { font-size: 9px; color: var(--swe-orange); }
.swe-histlow-date { font-size: 9px; color: var(--swe-text-2); }
.swe-histlow-src { font-size: 8px; padding: 1px 4px; border-radius: 2px; background: rgba(255,255,255,0.08); color: var(--swe-text-2); }
.swe-histlow-trend { grid-column: 1 / -1; opacity: 0.7; }
.swe-histlow-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 30px 20px; color: var(--swe-text-2); text-align: center; }
.swe-histlow-empty svg { width: 32px; height: 32px; opacity: 0.5; }
.swe-histlow-empty p { font-size: 12px; margin: 0; }
/* AI 预测面板 */
.swe-histlow-ai { margin-top: 12px; border-radius: var(--swe-radius-sm); border: 1px solid rgba(139,92,246,0.2); background: rgba(139,92,246,0.04); overflow: hidden; }
.swe-histlow-ai-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid rgba(139,92,246,0.15); }
.swe-histlow-ai-title { font-size: 13px; font-weight: 700; color: var(--swe-text-bright); display: flex; align-items: center; gap: 6px; }
.swe-histlow-ai-title svg { width: 16px; height: 16px; color: var(--swe-accent); }
.swe-histlow-ai-sub { font-size: 10px; color: var(--swe-text-2); font-weight: 400; margin-left: 6px; }
.swe-histlow-ai-body { padding: 12px 14px; }
.swe-histlow-ai-loading { display: flex; align-items: center; gap: 8px; color: var(--swe-accent); font-size: 13px; padding: 10px 0; }
.swe-histlow-ai-loading svg { width: 16px; height: 16px; animation: swe-spin 1s linear infinite; transform-origin: 50% 50%; }
.swe-histlow-ai-error { color: var(--swe-red); font-size: 12px; padding: 8px 0; }
.swe-histlow-ai-empty { text-align: center; padding: 16px 0; color: var(--swe-text-2); font-size: 12px; }
.swe-histlow-ai-summary { display: flex; gap: 8px; margin-bottom: 12px; font-size: 12px; color: var(--swe-text); line-height: 1.6; }
.swe-histlow-ai-summary svg { width: 16px; height: 16px; color: var(--swe-accent); flex-shrink: 0; margin-top: 2px; }
.swe-histlow-ai-summary p { margin: 0; }
.swe-histlow-ai-sec { margin-bottom: 10px; }
.swe-histlow-ai-sec-title { font-size: 12px; font-weight: 700; margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
.swe-histlow-ai-sec-title svg { width: 14px; height: 14px; }
.swe-histlow-ai-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 11px; border-bottom: 1px solid rgba(255,255,255,0.03); }
.swe-histlow-ai-item:last-child { border-bottom: none; }
.swe-histlow-ai-item-name { font-weight: 600; color: var(--swe-text-bright); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; flex-shrink: 0; }
.swe-histlow-ai-item-reason { flex: 1; color: var(--swe-text-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swe-histlow-ai-tag { font-size: 9px; padding: 1px 6px; border-radius: 3px; font-weight: 600; text-transform: uppercase; flex-shrink: 0; }
.swe-histlow-ai-sub { font-size: 10px; color: var(--swe-text-2); flex-shrink: 0; }
`;
// ============================================================
// 国际化
// ============================================================
const ZH = {
total: '总计', games: '游戏', searchPlaceholder: '搜索游戏名称\u2026',
filter: '筛选', sortBy: '排序', clearFilter: '清除筛选',
exportCSV: 'CSV', exportJSON: 'JSON',
exportFailed: '导出失败', exportNoData: '没有数据可导出', exportSuccess: '导出成功',
closeBtn: '关闭', loadAll: '加载全部', refreshData: '更新数据', loading: '加载中\u2026', loadingDone: '加载完成',
loadingProgress: '已加载 {loaded} 款',
dataNote: '数据仅供参考,实际以系统记录为准', noData: '暂无数据',
clearCache: '清除缓存', clearCacheConfirm: '确定要清除所有缓存并刷新页面吗?',
cacheRestored: '已恢复 {count} 条缓存数据', cacheExpired: '缓存已过期',
loadStatusInit: '正在初始化\u2026', loadStatusCache: '正在恢复缓存\u2026',
loadStatusFetch: '正在加载第{page}页\u2026 ({loaded}/{total})',
loadStatusDone: '加载完成', loadStatusEnrich: '正在补充详情\u2026 ({done}/{total})',
priorityAsc: '优先级 \u2191', priorityDesc: '优先级 \u2193',
nameAsc: '名称 A-Z', nameDesc: '名称 Z-A',
priceAsc: '价格 \u2191', priceDesc: '价格 \u2193',
discountDesc: '折扣 \u2193', discountAsc: '折扣 \u2191',
addedDesc: '添加日期 \u2193', addedAsc: '添加日期 \u2191',
reviewDesc: '评分 \u2193', reviewAsc: '评分 \u2191',
priceFrom: '价格\u2265', priceTo: '\u2264',
discountFrom: '折扣\u2265', discountTo: '\u2264',
onlyDiscounted: '仅打折', allItems: '全部',
// Tabs
tabOverview: '概览', tabWishlist: '愿望单', tabAnalytics: '分析', tabTrends: '趋势',
cardView: '封面视图', listView: '列表视图', coverView: '横板封面',
// KPI
kpiTotal: '愿望单总数', kpiValue: '总价值', kpiSale: '打折中', kpiFree: '免费',
kpiDiscount: '平均折扣', kpiYear: '年份跨度',
kpiEarliest: '最早', kpiAvgPrice: '均价', kpiMedian: '中位数', kpiMaxDiscount: '最大折扣',
kpiDiscountedRate: '打折比例', kpiEA: '抢先体验', kpiSoon: '即将推出',
kpiReleased: '已发售', kpiSaved: '已节省', kpiAvgReview: '平均评价', kpiDiscountRate: '折扣率',
// Charts
chartPriceDist: '价格区间分布', chartDiscountDist: '折扣分布', chartPriority: '优先级分布',
chartTopDiscount: '折扣 TOP 10', chartTags: '热门标签 TOP 15', chartTimeline: '添加时间线',
chartPlatform: '平台分布', chartType: '类型分布', chartReview: '评分分布',
chartYearTrend: '年度添加趋势', chartHeatmap: '添加热力图',
heatmapAll: '全部', heatmapLess: '少', heatmapMore: '多',
heatmapTotal: '总', heatmapPeak: '峰值', heatmapAvg: '日均',
// AI 分析 & 设置
settingsTitle: '设置', save: '保存', saved: '已保存',
aiSectionTitle: 'AI 设置', aiApiUrlLabel: 'API 地址', aiApiKeyLabel: 'API Key', aiModelLabel: '模型名称',
aiApiUrlHelp: '默认为 DeepSeek 官方 API 地址,也可填入其他兼容 OpenAI 格式的 API 地址',
aiApiKeyHelp: '在 platform.deepseek.com 注册并创建 API Key(sk- 开头),Key 仅保存在本地浏览器',
aiModelHelp: '默认为 deepseek-v4-pro,可修改为其他模型名称',
aiTitle: 'AI 犀利点评', aiSubtitle: '让 AI 毒舌总结你的愿望单',
aiStart: '开始分析', aiAnalyzing: '分析中…', aiRetry: '重试', aiReanalyze: '重新分析',
aiEmptyHint: 'AI 将从愿望单概况、消费画像、偏好画像、亮点槽点、入手建议五个维度犀利点评',
aiNotConfigured: '未配置 AI API Key,请先在顶部设置中配置',
aiGotoSettings: '去配置', aiFailed: '分析失败', aiTruncated: 'AI 响应因长度限制被截断,已修复恢复部分结果',
aiSecOverview: '愿望单概况', aiSecSpending: '消费画像', aiSecPreference: '偏好画像', aiSecHighlights: '亮点槽点', aiSecAdvice: '入手建议',
chartValueBreakdown: '价值构成', chartStatusDist: '状态分布', fullPrice: '全价',
chartAaaWish: '大作夙愿', aaaWishSub: '年度大作 × 愿望单', aaaWishEmpty: '愿望单中暂无匹配的年度大作',
aaaWishLoading: '正在获取年度大作数据…', aaaWishSoon: '即将发售', aaaWishOwned: '已拥有', aaaWishNotOwned: '未拥有',
priceFree: '免费', priceLt50: '<50', price50_100: '50-100', price100_200: '100-200',
price200_500: '200-500', priceGte500: '500+', priceNoPrice: '无价格',
chartUpcoming: '待上市游戏', up30: '30天内', up90: '90天内', upLater: '更远', upTBA: '未定日期',
// v1.1.3 待上市 Top 5 倒计时标签
upTopTitle: '即将上市 Top 5', upDay: '{n}天后', upToday: '今天发售', upReleased: '已发售', upTbaLabel: '未定',
disc0: '0%', disc1_25: '1-25%', disc26_50: '26-50%', disc51_75: '51-75%', disc76plus: '76%+',
// v1.1.0 我的类别统计
chartCategory: '我的类别分布', catUncategorized: '未分类', catNoCategory: '暂无类别', categoryView: '类别视图',
// Stats
statsTotalValue: '总价值', statsAvgPrice: '平均价格', statsMedianPrice: '中位数',
statsDiscountedCount: '打折数量', statsFreeCount: '免费数量', statsEA: '抢先体验',
statsComingSoon: '即将推出', statsAvgDiscount: '平均折扣', statsMaxDiscount: '最大折扣',
// Types
typeGame: '游戏', typeDLC: 'DLC', typeSoftware: '软件', typeOther: '其他',
// CSV
csvAppId: 'AppID', csvName: '名称', csvPriority: '优先级', csvAddedDate: '添加日期',
csvFinalPrice: '现价', csvOriginalPrice: '原价', csvDiscount: '折扣(%)',
csvReviewScore: '评分', csvReviewPercent: '好评率(%)', csvIsEarlyAccess: '抢先体验',
csvIsFree: '免费', csvIsComingSoon: '即将推出', csvReleaseDate: '发行日期',
csvStoreUrl: '商店链接', csvType: '类型', csvTags: '标签',
csvPlatforms: '平台', csvDeckCompat: 'Deck兼容', csvCapsule: '封面URL',
csvCategoryNames: '愿望单分类',
prev: '上一页', next: '下一页', page: '第',
weeksAgo: '周前', months: '月', sun: '日', mon: '一', tue: '二', wed: '三', thu: '四', fri: '五', sat: '六',
trendModeYear: '年', trendModeQuarter: '季', trendModeMonth: '月',
chartTrendTitle: '添加趋势',
// v1.1.0 愿望单增强
onlyOwned: '仅显示已拥有', batchManage: '批量管理', batchExit: '退出批量',
selectAll: '全选', unselectAll: '取消全选', removeSelected: '移除选中',
batchSelectedCount: '已选 {count} 项', batchEmpty: '未选择任何项目',
batchRemoveConfirm: '确定要从愿望单移除以下 {count} 款游戏吗?此操作不可撤销。',
batchRemoveTitle: '批量移除愿望单', batchRemoving: '正在移除\u2026 ({done}/{total})',
batchRemoveDone: '已移除 {done} 款游戏', batchRemoveFailed: '{failed} 款移除失败',
batchRemoveSingleFail: '移除失败', ownedYes: '已拥有', ownedNo: '未拥有',
phLoading: '查询史低\u2026', phNone: '无史低', phLow: '史低', phNear: '距史低', phHigh: '距史低',
phHistLow: '史低 {price}', phDistFromLow: '距史低 +{pct}%', priceHistError: '价格历史查询失败',
// v1.2.0 史低分析 & AI 价格预测
histLowSection: '史低价格分析', histLowSectionSub: '查询打折游戏历史最低价',
histLowScan: '扫描史低', histLowScanning: '正在扫描史低\u2026 ({done}/{total})', histLowScanDone: '史低扫描完成',
histLowAtLow: '已达史低', histLowNearLow: '接近史低', histLowAboveLow: '高于史低', histLowNoData: '无史低数据',
histLowSummary: '共 {total} 款打折游戏', histLowAtLowCount: '已达史低', histLowNearLowCount: '接近史低', histLowAboveLowCount: '高于史低',
histLowGame: '游戏', histLowCurrent: '当前价', histLowLowest: '史低价', histLowDiscount: '当前折扣', histLowMaxCut: '历史最大折扣',
histLowSource: '数据源', histLowDate: '史低日期', histLowTrend: '折扣趋势',
aiPredictTitle: 'AI 价格预测', aiPredictSub: '基于历史价格趋势预测入手时机',
aiPredictStart: 'AI 预测', aiPredictAnalyzing: 'AI 正在分析价格趋势\u2026', aiPredictRetry: '重新预测',
aiPredictNoData: '需要先扫描史低数据', aiPredictHint: 'AI 分析打折游戏的历史价格走势,预测是否值得入手或继续等待',
aiPredictBuyNow: '建议入手', aiPredictWait: '建议等待', aiPredictHistoricalLow: '史低突破预测',
aiPredictSecSummary: '总体分析', aiPredictSecBuyNow: '建议入手', aiPredictSecWait: '建议等待', aiPredictSecBreakthrough: '史低突破预测',
ownershipChecking: '正在检查拥有状态\u2026 ({done}/{total})', ownershipCheckFailed: '拥有状态检查失败',
wishlistRemoveOk: '已从愿望单移除',
quarterLabel: 'Q', monthLabels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
};
const ZH_TW = { ...ZH,
total: '總計', games: '遊戲', searchPlaceholder: '搜尋遊戲名稱\u2026',
filter: '篩選', clearFilter: '清除篩選', loading: '載入中\u2026', loadingDone: '載入完成',
noData: '暫無資料', clearCache: '清除快取', cacheRestored: '已恢復 {count} 條快取資料', cacheExpired: '快取已過期',
loadStatusCache: '正在恢復快取\u2026', loadStatusFetch: '正在載入第{page}頁\u2026 ({loaded}/{total})',
loadStatusDone: '載入完成', loadStatusEnrich: '正在補充詳情\u2026 ({done}/{total})',
tabOverview: '概覽', tabWishlist: '願望單', tabAnalytics: '分析', tabTrends: '趨勢',
cardView: '封面視圖', listView: '列表視圖', coverView: '橫板封面',
chartCategory: '我的類別分佈', catUncategorized: '未分類', catNoCategory: '暫無類別', categoryView: '類別視圖',
// v1.1.3 待上市 Top 5
upTopTitle: '即將上市 Top 5', upDay: '{n}天後', upToday: '今天發售', upReleased: '已發售', upTbaLabel: '未定',
};
const EN = { ...ZH,
total: 'Total', games: 'games', searchPlaceholder: 'Search game name\u2026',
filter: 'Filter', clearFilter: 'Clear', loading: 'Loading\u2026', loadingDone: 'Loading Complete',
loadAll: 'Load All', refreshData: 'Refresh Data', closeBtn: 'Close', exportFailed: 'Export failed', exportNoData: 'No data to export', exportSuccess: 'Export success',
noData: 'No Data', clearCache: 'Clear Cache', clearCacheConfirm: 'Clear all cache and reload?',
cacheRestored: 'Restored {count} cached items', cacheExpired: 'Cache expired',
loadStatusInit: 'Initializing\u2026', loadStatusCache: 'Restoring cache\u2026',
loadStatusFetch: 'Loading page {page}\u2026 ({loaded}/{total})',
loadStatusDone: 'Loading complete', loadStatusEnrich: 'Enriching\u2026 ({done}/{total})',
priorityAsc: 'Priority \u2191', priorityDesc: 'Priority \u2193',
nameAsc: 'Name A-Z', nameDesc: 'Name Z-A',
priceAsc: 'Price \u2191', priceDesc: 'Price \u2193',
discountDesc: 'Discount \u2193', discountAsc: 'Discount \u2191',
addedDesc: 'Added \u2193', addedAsc: 'Added \u2191',
reviewDesc: 'Review \u2193', reviewAsc: 'Review \u2191',
onlyDiscounted: 'Discounted', allItems: 'All',
tabOverview: 'Overview', tabWishlist: 'Wishlist', tabAnalytics: 'Analytics', tabTrends: 'Trends',
cardView: 'Cover View', listView: 'List View', coverView: 'Wide Cover',
kpiTotal: 'Wishlist Total', kpiValue: 'Total Value', kpiSale: 'On Sale', kpiFree: 'Free',
kpiDiscount: 'Avg Discount', kpiYear: 'Year Span',
kpiEarliest: 'Since', kpiAvgPrice: 'Avg Price', kpiMedian: 'Median', kpiMaxDiscount: 'Max Discount',
kpiDiscountedRate: 'Discount Rate', kpiEA: 'Early Access', kpiSoon: 'Coming Soon',
kpiReleased: 'Released', kpiSaved: 'Saved', kpiAvgReview: 'Avg Review', kpiDiscountRate: 'Discount Rate',
chartPriceDist: 'Price Distribution', chartDiscountDist: 'Discount Distribution',
chartPriority: 'Priority Distribution', chartTopDiscount: 'TOP 10 Discounts',
chartTags: 'Top 15 Tags', chartTimeline: 'Added Timeline', chartPlatform: 'Platform Distribution',
chartType: 'Type Distribution', chartReview: 'Review Distribution',
chartYearTrend: 'Yearly Trend', chartHeatmap: 'Activity Heatmap',
heatmapAll: 'All', heatmapLess: 'Less', heatmapMore: 'More',
heatmapTotal: 'Total', heatmapPeak: 'Peak', heatmapAvg: 'Avg',
// AI analysis & settings
settingsTitle: 'Settings', save: 'Save', saved: 'Saved',
aiSectionTitle: 'AI Settings', aiApiUrlLabel: 'API URL', aiApiKeyLabel: 'API Key', aiModelLabel: 'Model',
aiApiUrlHelp: 'Default is DeepSeek official API. Any OpenAI-compatible endpoint also works.',
aiApiKeyHelp: 'Create an API Key at platform.deepseek.com (starts with sk-). Stored locally only.',
aiModelHelp: 'Default is deepseek-v4-pro. Change to any model name you prefer.',
aiTitle: 'AI Sharp Review', aiSubtitle: 'Let AI roast your wishlist',
aiStart: 'Analyze', aiAnalyzing: 'Analyzing…', aiRetry: 'Retry', aiReanalyze: 'Re-analyze',
aiEmptyHint: 'AI reviews your wishlist across 5 dimensions: overview, spending, taste, highlights & advice',
aiNotConfigured: 'AI API Key not configured. Open Settings in the header first.',
aiGotoSettings: 'Configure', aiFailed: 'Analysis failed', aiTruncated: 'AI response was truncated; partial results recovered.',
aiSecOverview: 'Wishlist Overview', aiSecSpending: 'Spending Profile', aiSecPreference: 'Taste Profile', aiSecHighlights: 'Highlights & Roasts', aiSecAdvice: 'Buying Advice',
chartValueBreakdown: 'Value Breakdown', chartStatusDist: 'Status', fullPrice: 'Full Price',
chartAaaWish: 'AAA Wishes', aaaWishSub: 'Annual AAA × Wishlist', aaaWishEmpty: 'No matching AAA games in wishlist',
aaaWishLoading: 'Fetching AAA games data…', aaaWishSoon: 'Coming Soon', aaaWishOwned: 'Owned', aaaWishNotOwned: 'Not Owned',
priceFree: 'Free', priceLt50: '<50', price50_100: '50-100', price100_200: '100-200',
price200_500: '200-500', priceGte500: '500+', priceNoPrice: 'No Price',
chartUpcoming: 'Upcoming Games', up30: 'In 30d', up90: 'In 90d', upLater: 'Later', upTBA: 'TBA',
// v1.1.3 Upcoming Top 5 countdown
upTopTitle: 'Top 5 Upcoming', upDay: 'in {n}d', upToday: 'Releases today', upReleased: 'Released', upTbaLabel: 'TBA',
disc0: '0%', disc1_25: '1-25%', disc26_50: '26-50%', disc51_75: '51-75%', disc76plus: '76%+',
// v1.1.0 我的类别统计
chartCategory: 'My Categories', catUncategorized: 'Uncategorized', catNoCategory: 'No categories', categoryView: 'Category View',
statsTotalValue: 'Total Value', statsAvgPrice: 'Avg Price', statsMedianPrice: 'Median',
statsDiscountedCount: 'Discounted', statsFreeCount: 'Free', statsEA: 'Early Access',
statsComingSoon: 'Coming Soon', statsAvgDiscount: 'Avg Discount', statsMaxDiscount: 'Max Discount',
typeGame: 'Game', typeDLC: 'DLC', typeSoftware: 'Software', typeOther: 'Other',
prev: 'Prev', next: 'Next', page: 'Page',
weeksAgo: 'w ago', months: 'mo',
trendModeYear: 'Year', trendModeQuarter: 'Quarter', trendModeMonth: 'Month',
chartTrendTitle: 'Addition Trend',
// v1.1.0 wishlist enhancements
onlyOwned: 'Owned Only', batchManage: 'Batch', batchExit: 'Exit Batch',
selectAll: 'Select All', unselectAll: 'Clear', removeSelected: 'Remove Selected',
batchSelectedCount: '{count} selected', batchEmpty: 'No items selected',
batchRemoveConfirm: 'Remove the following {count} games from your wishlist? This cannot be undone.',
batchRemoveTitle: 'Batch Remove from Wishlist', batchRemoving: 'Removing\u2026 ({done}/{total})',
batchRemoveDone: 'Removed {done} games', batchRemoveFailed: '{failed} failed',
batchRemoveSingleFail: 'Failed', ownedYes: 'Owned', ownedNo: 'Not Owned',
phLoading: 'Checking low\u2026', phNone: 'No low', phLow: 'All-time Low', phNear: 'Near low', phHigh: 'Above low',
phHistLow: 'Low {price}', phDistFromLow: '+{pct}% above low', priceHistError: 'Price history query failed',
// v1.2.0 Historical low analysis & AI prediction
histLowSection: 'Historical Low Analysis', histLowSectionSub: 'Check discounted games for all-time lows',
histLowScan: 'Scan Lows', histLowScanning: 'Scanning lows\u2026 ({done}/{total})', histLowScanDone: 'Low scan complete',
histLowAtLow: 'At Low', histLowNearLow: 'Near Low', histLowAboveLow: 'Above Low', histLowNoData: 'No Data',
histLowSummary: '{total} discounted games', histLowAtLowCount: 'At Low', histLowNearLowCount: 'Near Low', histLowAboveLowCount: 'Above Low',
histLowGame: 'Game', histLowCurrent: 'Current', histLowLowest: 'Lowest', histLowDiscount: 'Discount', histLowMaxCut: 'Max Cut',
histLowSource: 'Source', histLowDate: 'Low Date', histLowTrend: 'Discount Trend',
aiPredictTitle: 'AI Price Prediction', aiPredictSub: 'Predict purchase timing from price trends',
aiPredictStart: 'AI Predict', aiPredictAnalyzing: 'AI analyzing price trends\u2026', aiPredictRetry: 'Re-predict',
aiPredictNoData: 'Scan historical lows first', aiPredictHint: 'AI analyzes price history to predict whether to buy now or wait',
aiPredictBuyNow: 'Buy Now', aiPredictWait: 'Wait', aiPredictHistoricalLow: 'Low Breakthrough',
aiPredictSecSummary: 'Summary', aiPredictSecBuyNow: 'Buy Now', aiPredictSecWait: 'Wait', aiPredictSecBreakthrough: 'Breakthrough',
ownershipChecking: 'Checking ownership\u2026 ({done}/{total})', ownershipCheckFailed: 'Ownership check failed',
wishlistRemoveOk: 'Removed from wishlist',
monthLabels: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
};
const I18N = { 'zh-CN': ZH, 'zh-TW': ZH_TW, 'zh-HK': ZH_TW, 'zh-MO': ZH_TW, 'en': EN };
// ============================================================
// 全局状态
// ============================================================
let locale = 'zh-CN';
let steamId = '';
let isInitialized = false;
let stopLoading = false;
let activeTab = 'overview';
let panelOpen = false;
let trendMode = 'year'; // 'year' | 'quarter' | 'month'
let heatmapViews = null; // v1.0.4: 热力图各年份预计算视图(年份切换时直接替换)
const state = {
items: [],
filtered: [],
loading: false,
allLoaded: false,
ssrLoaded: false,
search: '',
sortBy: 'priority-asc',
viewMode: 'card',
page: 1,
gameFilter: 'all',
currency: null,
currencySource: null,
// v1.1.0 愿望单增强
onlyOwned: false, // 仅显示已拥有
ownershipChecked: false, // 拥有状态是否已批量检查过
ownershipChecking: false, // 正在检查拥有状态
batchMode: false, // 批量管理模式
batchSelected: new Set(), // 批量选中的 appid 集合
batchRemoving: false, // 正在批量移除
priceHistCache: {}, // 内存态价格历史缓存 {appid: {lowest, cur, source, ts}}
fullPhCache: {}, // v1.2.0: 完整价格历史缓存 {appid: {history, discounts, lowest, ts}}
wishlistCategoryNames: {}, // v1.1.0: 用户自定义愿望单类别 ID -> 名称映射
categoryView: false, // v1.1.1: 愿望单类别视图(左右分栏)
categoryFilter: null, // v1.1.1: 当前选中的类别 ID(null=全部,'__uncat__'=未分类)
fxRates: null, // 汇率表 {USD:1, CNY:7.2,...}
fxLoadedAt: 0,
};
const ui = {};
// ============================================================
// 工具函数
// ============================================================
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';
}
function t(k, vars = {}) {
let s = I18N[locale]?.[k] ?? ZH[k] ?? k;
if (vars && typeof s === 'string') {
for (const [vk, vv] of Object.entries(vars)) s = s.replace(new RegExp(`\\{${vk}\\}`, 'g'), String(vv));
}
return s;
}
const STEAM_LANG_MAP = { 'zh-CN': 'schinese', 'zh-TW': 'tchinese', 'zh-HK': 'tchinese', 'zh-MO': 'tchinese', 'en': 'english' };
function resolveYen(text) { return /\.\d+/.test(String(text)) ? 'CNY' : 'JPY'; }
function matchCurrency(text) {
if (!text) return null;
const norm = String(text).trim();
if (/¥/.test(norm) && !/JP¥|JPY/i.test(norm)) return getCurrencyById(resolveYen(norm));
for (const c of CURRENCIES) {
if (c.id === 'CNY' || c.id === 'JPY') continue;
if (c.match.test(norm)) return c;
}
return null;
}
const WALLET_CURRENCY_MAP = {
1: 'USD', 2: 'GBP', 3: 'EUR', 5: 'RUB', 7: 'BRL', 8: 'JPY',
10: 'IDR', 11: 'MYR', 12: 'PHP', 13: 'SGD', 14: 'THB', 15: 'VND',
16: 'KRW', 17: 'TRY', 19: 'MXN', 20: 'CAD', 21: 'AUD', 23: 'CNY',
24: 'INR', 29: 'HKD', 30: 'TWD', 34: 'ARS'
};
function detectSteamRegionCurrency() {
try {
const uw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
const wc = Number(uw.g_rgWalletInfo?.wallet_currency);
if (wc && WALLET_CURRENCY_MAP[wc]) return { cur: getCurrencyById(WALLET_CURRENCY_MAP[wc]), source: 'wallet' };
const balText = document.querySelector('#header_wallet_balance')?.textContent?.trim();
if (balText) { const cur = matchCurrency(balText); if (cur) return { cur, source: 'wallet' }; }
const cc = String(uw.g_strCountryCode || '').toUpperCase();
if (cc) { const cur = CURRENCIES.find(c => c.cc === cc); if (cur) return { cur, source: 'country' }; }
} catch (e) { console.warn('[SWE] region currency detect failed:', e); }
return null;
}
function detectUserCurrency(priceTexts = []) {
const region = detectSteamRegionCurrency();
if (region) return region;
for (const text of priceTexts) { const cur = matchCurrency(text); if (cur) return { cur, source: 'prices' }; }
try {
const localData = tryExtractLocalData();
if (localData?.storeItemCache) {
for (const sd of localData.storeItemCache.values()) {
const bpo = sd?.default_info?.best_purchase_option;
const txt = bpo?.formatted_final_price || bpo?.formatted_original_price || '';
const cur = matchCurrency(txt);
if (cur) return { cur, source: 'ssr' };
}
}
} catch (e) { console.warn('[SWE] currency detect SSR failed:', e); }
const urlCc = new URLSearchParams(window.location.search).get('cc')?.toUpperCase();
if (urlCc) { const cur = CURRENCIES.find(c => c.cc === urlCc); if (cur) return { cur, source: 'url' }; }
const lang = (document.documentElement.lang || '').toLowerCase();
const LANG_TO_CUR = {
'zh-cn': 'CNY', 'zh-tw': 'TWD', 'zh-hk': 'HKD', 'zh-mo': 'HKD',
'ja': 'JPY', 'ko': 'KRW', 'ru': 'RUB', 'tr': 'TRY',
'en-us': 'USD', 'en-gb': 'GBP', 'en-au': 'AUD', 'en-ca': 'CAD',
'de': 'EUR', 'fr': 'EUR', 'it': 'EUR', 'es': 'EUR', 'pt-br': 'BRL', 'es-mx': 'MXN', 'es-ar': 'ARS',
'th': 'THB', 'ms': 'MYR', 'fil': 'PHP', 'id': 'IDR', 'vi': 'VND', 'hi': 'INR'
};
if (LANG_TO_CUR[lang]) return { cur: getCurrencyById(LANG_TO_CUR[lang]), source: 'lang' };
if (lang.startsWith('zh')) return { cur: getCurrencyById('CNY'), source: 'lang' };
if (lang.startsWith('en')) return { cur: getCurrencyById('USD'), source: 'lang' };
return { cur: getCurrencyById('CNY'), source: 'default' };
}
function updateCurrencyFromItems(items) {
if (!items || items.length === 0 || !state.currency) return;
if (state.currencySource !== 'lang' && state.currencySource !== 'default') return;
for (const item of items) {
const text = item.formattedPrice || item.formattedOrigPrice || '';
if (!text) continue;
const cur = matchCurrency(text);
if (cur && cur.id !== state.currency.id) {
state.currency = cur; state.currencySource = 'prices';
console.log('[SWE] currency updated from API:', cur.id);
break;
}
}
}
const escHtml = (() => { const d = document.createElement('div'); return s => { d.textContent = s; return d.innerHTML; }; })();
function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'text') el.textContent = v;
else if (k === 'html') el.innerHTML = v;
else if (k === 'class') el.className = v;
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
else el.setAttribute(k, v);
}
const appendChild = child => {
if (Array.isArray(child)) child.forEach(appendChild);
else if (typeof child === 'string') el.appendChild(document.createTextNode(child));
else if (child instanceof Node) el.appendChild(child);
};
children.forEach(appendChild);
return el;
}
function toast(msg, type = 'info', ms = CONFIG.TOAST_DURATION) {
const el = h('div', { class: `swe-toast swe-toast--${type}`, text: msg, role: 'status', 'aria-live': 'polite' });
document.documentElement.appendChild(el);
requestAnimationFrame(() => el.classList.add('swe-toast--visible'));
setTimeout(() => { el.classList.remove('swe-toast--visible'); setTimeout(() => el.remove(), CONFIG.ANIMATION_DURATION); }, ms);
}
function showConfirm(msg) {
return new Promise(resolve => {
const modal = h('div', { class: 'swe-loading-overlay', style: { position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.6)', zIndex: '99999', display: 'flex', alignItems: 'center', justifyContent: 'center' } });
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.querySelector('#swe-confirm-cancel').addEventListener('click', () => { modal.remove(); resolve(false); });
modal.querySelector('#swe-confirm-ok').addEventListener('click', () => { modal.remove(); resolve(true); });
modal.addEventListener('click', e => { if (e.target === modal) { modal.remove(); resolve(false); } });
});
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function hashFingerprint(entry) {
const cids = Array.isArray(entry.category_ids) ? [...entry.category_ids].sort((a,b) => a-b).join(',') : '';
return `${entry.appid || entry.appId || 0}|${entry.date_added || entry.added || 0}|${entry.priority ?? 0}|${cids}`;
}
function getSteamId() { const m = window.location.pathname.match(/\/wishlist(?:new)?\/(?:id|profiles)\/([^/]+)/); return m ? m[1] : ''; }
function isProfileId(id) { return /^\d{17}$/.test(id); }
function getWishlistApiBase() {
const id = getSteamId();
return isProfileId(id) ? `https://store.steampowered.com/wishlist/profiles/${id}/wishlistdata/` : `https://store.steampowered.com/wishlist/id/${id}/wishlistdata/`;
}
// ============================================================
// 网络请求
// ============================================================
function gmFetch(url, responseType = 'json') {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout: CONFIG.FETCH_TIMEOUT, responseType,
onload: resp => { resp.status === 200 ? resolve(resp.response) : reject(new Error(`HTTP ${resp.status}`)); },
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout'))
});
});
}
function gmFetchText(url) { return gmFetch(url, 'text'); }
// ====================================================================
// v1.1.0 愿望单增强模块
// ====================================================================
// ---- 通用:预留位限速器 + 并发 worker 池 ----
// 预留位模式:在 sleep 前更新 lastRequestTime,避免并发 worker 竞态导致请求间隔失效
function makeRateLimiter(intervalMs) {
let lastRequestTime = 0;
return async function reserve() {
const now = Date.now();
const wait = Math.max(0, lastRequestTime + intervalMs - now);
lastRequestTime = now + wait; // 关键:sleep 前预占下一个时间槽
if (wait > 0) await sleep(wait);
};
}
const steamRateReserve = makeRateLimiter(CONFIG.STEAM_REQ_INTERVAL); // Steam API:1s 间隔
const priceHistRateReserve = makeRateLimiter(CONFIG.PRICEHIST_REQ_INTERVAL); // 价格历史:300ms 间隔
// 通用 worker 池:workerCount 路并发消费 tasks,每个 task 调用 workerFn(task, idx)
async function runWorkerPool(tasks, workerCount, workerFn) {
let idx = 0;
async function worker() {
while (idx < tasks.length) {
const cur = idx++;
try { await workerFn(tasks[cur], cur); } catch (e) { /* 单项失败不阻断队列 */ }
}
}
const workers = [];
const n = Math.min(workerCount, tasks.length);
for (let i = 0; i < n; i++) workers.push(worker());
await Promise.all(workers);
}
// ---- 汇率:AugmentedSteam → open.er-api.com,1 小时缓存 ----
const SWE_FX_KEY = 'swe_fx_cache';
async function loadExchangeRates() {
const now = Date.now();
if (state.fxRates && (now - state.fxLoadedAt) < CONFIG.FX_CACHE_TTL) return state.fxRates;
try {
const cached = GM_getValue(SWE_FX_KEY);
if (cached && cached.ts && (now - cached.ts) < CONFIG.FX_CACHE_TTL && cached.rates) {
state.fxRates = cached.rates; state.fxLoadedAt = now; return state.fxRates;
}
} catch (e) {}
let rates = null;
// 1) AugmentedSteam(尽力而为)
try {
const data = await gmFetch('https://api.enhancedsteam.com/v1/supportedcurrencies/', 'json');
if (data && data.rates) rates = data.rates;
} catch (e) {}
// 2) open.er-api.com(可靠回退,base=USD)
if (!rates) {
try {
const data = await gmFetch('https://open.er-api.com/v6/latest/USD', 'json');
if (data && data.rates) rates = data.rates;
} catch (e) { console.warn('[SWE] fx fetch failed:', e); }
}
if (rates) { state.fxRates = rates; state.fxLoadedAt = now; try { GM_setValue(SWE_FX_KEY, { rates, ts: now, base: 'USD' }); } catch (e) {} }
return rates;
}
// 金额换算:fromCur → toCur(rates 以 USD 为基准,即 1 USD = rates[cur] 个 cur)
function convertCurrency(amount, fromCur, toCur, rates) {
const v = Number(amount);
if (isNaN(v)) return null;
if (!fromCur || !toCur || fromCur === toCur) return v;
if (!rates) return null;
const fr = Number(rates[fromCur]);
const tr = Number(rates[toCur]);
if (!fr || isNaN(fr) || fr <= 0 || !tr || isNaN(tr) || tr <= 0) return null;
return (v / fr) * tr; // fromCur → USD → toCur
}
// ---- 价格历史:ITAD → CheapShark → AugmentedSteam ----
const SWE_PRICEHIST_KEY = 'swe_pricehist_cache';
function loadPriceHistCache() {
try {
const raw = GM_getValue(SWE_PRICEHIST_KEY);
if (raw && typeof raw === 'object') {
for (const k of Object.keys(raw)) if (!state.priceHistCache[k]) state.priceHistCache[k] = raw[k];
}
} catch (e) {}
}
function savePriceHistCache() { try { GM_setValue(SWE_PRICEHIST_KEY, state.priceHistCache); } catch (e) {} }
const _priceHistPending = new Map();
// ITAD:需用户配置 key(GM_getValue 'swe_itad_key'),无 key 直接跳过走下一回退
function getItadKey() { try { return GM_getValue('swe_itad_key', ''); } catch (e) { return ''; } }
async function fetchITADLow(appId) {
const key = getItadKey();
if (!key) return null;
// steam appid → plain(字符串 URL 拼接,不用 new URL())
const lookupUrl = 'https://api.isthereanydeal.com/v01/id/lookup/v1/?key=' + encodeURIComponent(key) + '&shop=steam&gameid=' + encodeURIComponent(appId);
const lookup = await gmFetch(lookupUrl, 'json');
const plainKey = lookup && lookup.data && Object.keys(lookup.data)[0];
if (!plainKey) return null;
const plain = (lookup.data[plainKey] && (lookup.data[plainKey].plain || lookup.data[plainKey])) || plainKey;
const lowUrl = 'https://api.isthereanydeal.com/v01/game/lowest/?key=' + encodeURIComponent(key) + '&plains=' + encodeURIComponent(plain) + '®ion=us&country=US';
const low = await gmFetch(lowUrl, 'json');
const rec = low && low.data && low.data[plain];
const p = Number(rec && rec.price);
if (!isNaN(p) && p > 0) return { lowest: p, cur: rec.currency || 'USD', source: 'ITAD', date: 0 };
return null;
}
// CheapShark:appids 参数(USD),cheapestPriceEver.price
async function fetchCheapSharkLow(appId) {
const url = 'https://api.cheapshark.com/api/v1.0/games?appids=' + encodeURIComponent(appId);
const data = await gmFetch(url, 'json');
const arr = Array.isArray(data) ? data : (data ? [data] : []);
for (const g of arr) {
const sa = g && g.info && (g.info.steamAppID || g.info.steamAppId);
if (sa && Number(sa) === Number(appId)) {
const cpe = g.cheapestPriceEver;
const p = Number(cpe && cpe.price);
if (!isNaN(p) && p > 0) return { lowest: p, cur: 'USD', source: 'CheapShark', date: Number(cpe && cpe.date) || 0 };
}
}
return null;
}
// AugmentedSteam:第三回退(尽力而为)
async function fetchAugmentedSteamLow(appId) {
const url = 'https://api.enhancedsteam.com/v2/price/?appids=' + encodeURIComponent(appId);
const data = await gmFetch(url, 'json');
const rec = data && (data[appId] || (data.data && data.data[appId]));
const p = Number(rec && (rec.lowest_price || rec.lowest || rec.price));
if (!isNaN(p) && p > 0) return { lowest: p, cur: rec.currency || 'USD', source: 'AugmentedSteam', date: 0 };
return null;
}
// 获取单个游戏历史最低(含缓存 + 回退链 + 汇率换算到用户货币)
async function getPriceHistLow(appId) {
const id = String(appId);
const cached = state.priceHistCache[id];
if (cached && cached.ts && (Date.now() - cached.ts) < CONFIG.PRICEHIST_CACHE_TTL) return cached;
if (_priceHistPending.has(id)) return _priceHistPending.get(id);
const p = (async () => {
await priceHistRateReserve(); // 预留位限速
let result = null;
try { result = await fetchITADLow(appId); } catch (e) {}
if (!result) { try { result = await fetchCheapSharkLow(appId); } catch (e) {} }
if (!result) { try { result = await fetchAugmentedSteamLow(appId); } catch (e) {} }
if (!result) return null;
// 换算到用户货币(价格字段用 Number + isNaN 安全转换)
const userCur = state.currency ? state.currency.id : 'CNY';
let lowestOut, fxOk;
if (result.cur === userCur) {
lowestOut = Number(result.lowest); fxOk = !isNaN(lowestOut);
} else {
const converted = convertCurrency(result.lowest, result.cur, userCur, state.fxRates);
lowestOut = converted != null ? converted : Number(result.lowest);
fxOk = converted != null && !isNaN(lowestOut);
}
const out = { lowest: lowestOut, cur: userCur, source: result.source, origLowest: Number(result.lowest), origCur: result.cur, ts: Date.now(), fxOk };
state.priceHistCache[id] = out;
savePriceHistCache();
return out;
})();
_priceHistPending.set(id, p);
try { return await p; } finally { _priceHistPending.delete(id); }
}
// 计算价格历史徽章:绿(史低) / 黄(接近史低≤110%) / 红(远高于史低)
function computePriceHistBadge(item, hist) {
if (!item || item.isFree) return null;
const cur = Number(item.finalPrice);
if (isNaN(cur) || cur <= 0) return null;
if (!hist || hist.lowest == null || hist.fxOk === false) return null; // 货币未换算成功则不显示,避免误判
const low = Number(hist.lowest);
if (isNaN(low) || low <= 0) return null;
if (cur <= low) {
// 当前价格等于或低于历史最低 → 史低(绿)
return { cls: 'ph-low', kind: 'low', text: t('phHistLow', { price: fmtPrice(low) }) };
}
const ratio = cur / low;
const pctOver = ((cur - low) / low) * 100;
if (ratio <= 1.10) {
// 当前价格在历史最低 110% 以内 → 接近史低(黄)
return { cls: 'ph-near', kind: 'near', text: t('phDistFromLow', { pct: pctOver.toFixed(0) }) };
}
// 远高于史低(红)
return { cls: 'ph-high', kind: 'high', text: t('phDistFromLow', { pct: pctOver.toFixed(0) }) };
}
function priceHistBadgeHtml(badge) {
if (!badge) return '';
return `${ICONS.trending}${escapeHTML(badge.text)}`;
}
// ============================================================
// v1.2.0 史低价格分析:完整折扣历史获取(ITAD v02 → CheapShark → AugmentedSteam)
// 参考 steam-game-library-viewer-2.9.7 fetchHistoryPrices 逻辑
// ============================================================
const SWE_FULLPH_KEY = 'swe_fullph_cache';
const _fullPhPending = new Map();
// ITAD v02 API Key:优先用户配置,回退内置 key(参考 sglv 2.9.7)
const ITAD_V02_KEY_FALLBACK = '58eb2271a08c1cf60bd812d701d09d5c694f502e';
const ITAD_V02_BASE = 'https://api.isthereanydeal.com';
const ITAD_SHOP_STEAM = 61;
function getItadV02Key() {
const userKey = getItadKey();
return userKey || ITAD_V02_KEY_FALLBACK;
}
// ITAD v02: Steam AppID → ITAD GameID
async function lookupItadIdV02(appId) {
const key = getItadV02Key();
const formattedId = `app/${appId}`;
try {
const data = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${ITAD_V02_BASE}/lookup/id/shop/${ITAD_SHOP_STEAM}/v1?key=${encodeURIComponent(key)}`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify([formattedId]),
timeout: 15000,
onload(r) {
if (r.status >= 200 && r.status < 300) {
try { resolve(JSON.parse(r.responseText)); }
catch { reject(new Error('JSON parse fail')); }
} else reject(new Error('HTTP ' + r.status));
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
if (data && data[formattedId]) return data[formattedId];
return null;
} catch (e) {
console.warn('[SWE] ITAD v02 lookup 失败:', e.message);
return null;
}
}
// ITAD v02: 获取游戏信息(发行日期等)
async function fetchItadGameInfoV02(itadId) {
const key = getItadV02Key();
try {
const url = `${ITAD_V02_BASE}/games/info/v2?key=${encodeURIComponent(key)}&id=${encodeURIComponent(itadId)}`;
return await gmFetch(url, 'json');
} catch (e) {
console.warn('[SWE] ITAD v02 game info 失败:', e.message);
return null;
}
}
// ITAD v02: 获取价格历史(含折扣记录)
async function fetchItadPriceHistoryV02(itadId, country = 'CN') {
const key = getItadV02Key();
const params = new URLSearchParams({
key, id: itadId, country, shops: String(ITAD_SHOP_STEAM),
});
try {
const url = `${ITAD_V02_BASE}/games/history/v2?${params}`;
let data = await gmFetch(url, 'json');
if (data && data.length > 0) return data;
// 尝试带 since 参数获取更早数据(5年前)
const sinceDate = new Date(Date.now() - 365 * 5 * 86400000).toISOString().replace(/\.\d+Z$/, '+00:00');
params.set('since', sinceDate);
data = await gmFetch(`${ITAD_V02_BASE}/games/history/v2?${params}`, 'json');
return data || [];
} catch (e) {
console.warn('[SWE] ITAD v02 price history 失败:', e.message);
return [];
}
}
// CheapShark: 获取完整 deals 历史(USD)
async function fetchCheapSharkFullHistory(appId) {
try {
const url = `https://api.cheapshark.com/api/1.0/games?id=${encodeURIComponent(appId)}`;
const data = await gmFetch(url, 'json');
if (!data || !data.deals || !data.deals.length) return null;
const history = data.deals.map(d => ({
price: Number(d.price) || 0,
regular: Number(d.retailPrice) || 0,
currency: 'USD',
cut: d.savings ? Math.round(Number(d.savings)) : 0,
store: d.storeName || '',
date: d.lastChange ? new Date(Number(d.lastChange) * 1000).toISOString() : '',
})).filter(h => h.price > 0).sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 20);
if (!history.length) return null;
const minPrice = Math.min(...history.map(h => h.price));
const lowestDeal = history.find(h => h.price === minPrice);
return {
history,
discounts: history.filter(h => h.cut > 0).sort((a, b) => b.cut - a.cut),
lowest: lowestDeal ? { price: lowestDeal.price, currency: 'USD', store: lowestDeal.store, date: lowestDeal.date, cut: lowestDeal.cut } : null,
releaseDate: null,
gameTitle: data.info?.title || '',
source: 'cheapshark',
};
} catch (e) {
console.warn('[SWE] CheapShark full history 失败:', e.message);
return null;
}
}
// AugmentedSteam: 第三回退
async function fetchAugmentedFullHistory(appId) {
try {
const data = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.augmentedsteam.com/prices/v2',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ country: 'US', apps: [parseInt(appId, 10)], subs: [], bundles: [], voucher: false, shops: [] }),
timeout: 12000,
onload(r) {
if (r.status >= 200 && r.status < 300) {
try { resolve(JSON.parse(r.responseText)); }
catch { reject(new Error('JSON parse fail')); }
} else reject(new Error('HTTP ' + r.status));
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
const key = `app/${appId}`;
const aug = data?.prices?.[key];
if (!aug) return null;
const lowest = aug.lowest || (aug.historic?.[0] ? { price: aug.historic[0].price, currency: aug.historic[0].currency, store: aug.historic[0].store || '', date: aug.historic[0].date || '', cut: 0 } : null);
const history = (aug.historic || []).map(h => ({
price: h.price, currency: h.currency, cut: 0, store: h.store || '', date: h.date || '', regular: 0,
})).sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 20);
return { history, discounts: [], lowest, releaseDate: null, gameTitle: '', source: 'aug' };
} catch (e) {
return null;
}
}
// 获取完整价格历史(含缓存 + 回退链 + 汇率换算)
async function getFullPriceHistory(appId) {
const id = String(appId);
// 先检查轻量缓存(已有的 getPriceHistLow 结果可直接复用 lowest)
const cached = state.fullPhCache[id];
if (cached && cached.ts && (Date.now() - cached.ts) < CONFIG.PRICEHIST_CACHE_TTL) return cached;
if (_fullPhPending.has(id)) return _fullPhPending.get(id);
const p = (async () => {
await priceHistRateReserve();
// 步骤1: ITAD v02 lookup
const itadId = await lookupItadIdV02(appId);
let result = null;
if (itadId) {
const [gameInfo, history] = await Promise.all([
fetchItadGameInfoV02(itadId),
fetchItadPriceHistoryV02(itadId),
]);
if (history && history.length) {
result = parseItadHistory(history, gameInfo);
}
}
// 步骤2: CheapShark 回退
if (!result) result = await fetchCheapSharkFullHistory(appId);
// 步骤3: AugmentedSteam 回退
if (!result) result = await fetchAugmentedFullHistory(appId);
if (!result) return null;
// 汇率换算到用户货币
const userCur = state.currency ? state.currency.id : 'CNY';
result = convertHistoryCurrency(result, userCur);
result.ts = Date.now();
state.fullPhCache[id] = result;
saveFullPhCache();
return result;
})();
_fullPhPending.set(id, p);
try { return await p; } finally { _fullPhPending.delete(id); }
}
// 解析 ITAD v02 价格历史 → 统一格式(参考 sglv 2.9.7 seen_cuts 逻辑)
function parseItadHistory(history, gameInfo) {
const releaseDate = gameInfo?.releaseDate || null;
const seenCuts = new Set();
const discounts = [];
const allHistory = [];
for (const item of history) {
const deal = item.deal || {};
const cut = deal.cut || 0;
const timestamp = item.timestamp || '';
const dealPrice = deal.price || {};
const regularPrice = deal.regular || {};
const shop = item.shop?.name || item.shop || '';
const historyItem = {
price: dealPrice.amount || 0,
regular: regularPrice.amount || 0,
currency: dealPrice.currency || '',
cut, store: shop, date: timestamp,
};
allHistory.push(historyItem);
if (cut > 0 && !seenCuts.has(cut)) {
seenCuts.add(cut);
let daysFromRelease = null;
if (releaseDate && timestamp) {
try { daysFromRelease = Math.floor((new Date(timestamp) - new Date(releaseDate)) / 86400000); }
catch { /* ignore */ }
}
discounts.push({ cut, price: historyItem.price, regular: historyItem.regular, currency: historyItem.currency, store: shop, date: timestamp, daysFromRelease });
}
}
discounts.sort((a, b) => b.cut - a.cut);
allHistory.sort((a, b) => new Date(b.date) - new Date(a.date));
let lowest = null;
if (discounts.length > 0) {
const maxCut = discounts[0];
lowest = { price: maxCut.price, currency: maxCut.currency, store: maxCut.store, date: maxCut.date, cut: maxCut.cut };
}
return {
history: allHistory.slice(0, 20),
discounts,
lowest,
releaseDate,
gameTitle: gameInfo?.title || '',
source: 'itad',
};
}
// 将历史价格数据换算到用户货币
function convertHistoryCurrency(data, userCur) {
if (!data || !data.lowest) return data;
const conv = (v, fromCur) => {
if (!v || !fromCur || fromCur === userCur) return Number(v) || 0;
const c = convertCurrency(Number(v), fromCur, userCur, state.fxRates);
return c != null ? c : Number(v) || 0;
};
const fxOk = data.lowest.currency === userCur || convertCurrency(1, data.lowest.currency, userCur, state.fxRates) != null;
if (data.lowest) {
data.lowest.origPrice = Number(data.lowest.price);
data.lowest.origCur = data.lowest.currency;
data.lowest.price = conv(data.lowest.price, data.lowest.currency);
data.lowest.currency = userCur;
data.lowest.fxOk = fxOk;
}
if (data.history) data.history.forEach(h => { h.price = conv(h.price, h.currency); h.regular = conv(h.regular, h.currency); h.currency = userCur; });
if (data.discounts) data.discounts.forEach(d => { d.price = conv(d.price, d.currency); d.regular = conv(d.regular, d.currency); d.currency = userCur; });
return data;
}
function loadFullPhCache() {
try {
if (!state.fullPhCache) state.fullPhCache = {};
const raw = GM_getValue(SWE_FULLPH_KEY);
if (raw && typeof raw === 'object') {
for (const k of Object.keys(raw)) if (!state.fullPhCache[k]) state.fullPhCache[k] = raw[k];
}
} catch (e) {}
}
function saveFullPhCache() { try { GM_setValue(SWE_FULLPH_KEY, state.fullPhCache || {}); } catch (e) {} }
// ============================================================
// v1.2.0 批量史低分析:扫描所有打折游戏,判定史低/接近史低/高于史低
// ============================================================
const histLowAnalysis = { results: [], loading: false, progress: 0, total: 0, done: false };
function getDiscountedItems() {
return state.items.filter(it => !it.isFree && it.discountPct > 0 && it.finalPrice > 0);
}
// 判定单个游戏史低状态:at-low / near-low / above-low / no-data
function classifyHistoricalLow(item, histData) {
if (!item || item.isFree) return 'no-data';
const cur = Number(item.finalPrice);
if (isNaN(cur) || cur <= 0) return 'no-data';
if (!histData || !histData.lowest || histData.lowest.fxOk === false) return 'no-data';
const low = Number(histData.lowest.price);
if (isNaN(low) || low <= 0) return 'no-data';
if (cur <= low * 1.001) return 'at-low';
if (cur <= low * 1.10) return 'near-low';
return 'above-low';
}
async function scanHistoricalLows() {
if (histLowAnalysis.loading) return;
const discounted = getDiscountedItems();
if (!discounted.length) { toast(t('histLowNoData'), 'info'); return; }
histLowAnalysis.loading = true;
histLowAnalysis.done = false;
histLowAnalysis.results = [];
histLowAnalysis.progress = 0;
histLowAnalysis.total = discounted.length;
loadFullPhCache();
await loadExchangeRates();
updateHistLowUI();
const tasks = discounted.map(it => ({ item: it, id: String(it.appId) }));
await runWorkerPool(tasks, CONFIG.PRICEHIST_WORKERS, async (tk) => {
try {
const histData = await getFullPriceHistory(tk.id);
const status = classifyHistoricalLow(tk.item, histData);
const maxCut = histData?.discounts?.length ? histData.discounts[0].cut : (histData?.lowest?.cut || 0);
histLowAnalysis.results.push({
appId: tk.id,
name: tk.item.name,
currentPrice: tk.item.finalPrice,
discountPct: tk.item.discountPct,
origPrice: tk.item.origPrice,
histData,
status,
maxCut,
lowestPrice: histData?.lowest?.price || null,
lowestDate: histData?.lowest?.date || null,
source: histData?.source || null,
});
} catch (e) {
histLowAnalysis.results.push({ appId: tk.id, name: tk.item.name, status: 'no-data', currentPrice: tk.item.finalPrice, discountPct: tk.item.discountPct });
}
histLowAnalysis.progress++;
if (histLowAnalysis.progress % 5 === 0 || histLowAnalysis.progress === histLowAnalysis.total) updateHistLowUI();
});
// 排序:已达史低 → 接近史低 → 高于史低 → 无数据;同组内按距史低幅度升序
const order = { 'at-low': 0, 'near-low': 1, 'above-low': 2, 'no-data': 3 };
histLowAnalysis.results.sort((a, b) => {
if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
const ra = a.lowestPrice != null ? a.currentPrice / a.lowestPrice : 999;
const rb = b.lowestPrice != null ? b.currentPrice / b.lowestPrice : 999;
return ra - rb;
});
histLowAnalysis.loading = false;
histLowAnalysis.done = true;
updateHistLowUI();
const atLow = histLowAnalysis.results.filter(r => r.status === 'at-low').length;
toast(t('histLowScanDone') + ` · ${atLow}/${histLowAnalysis.total}`, 'success');
}
// 更新史低分析面板 UI(局部刷新,不重渲染整个分析页)
function updateHistLowUI() {
const container = document.getElementById('swe-histlow-container');
if (!container) return;
container.innerHTML = renderHistLowContent();
// 绑定扫描按钮
const scanBtn = container.querySelector('#swe-histlow-scan');
if (scanBtn) scanBtn.addEventListener('click', () => scanHistoricalLows());
// 绑定 AI 预测按钮
const aiBtn = container.querySelector('#swe-histlow-ai');
if (aiBtn) aiBtn.addEventListener('click', () => triggerAiPricePrediction());
}
// 计算史低分析统计摘要
function computeHistLowStats() {
const results = histLowAnalysis.results;
return {
total: results.length,
atLow: results.filter(r => r.status === 'at-low').length,
nearLow: results.filter(r => r.status === 'near-low').length,
aboveLow: results.filter(r => r.status === 'above-low').length,
noData: results.filter(r => r.status === 'no-data').length,
};
}
// ============================================================
// v1.2.0 AI 价格预测:基于历史价格和折扣趋势预测入手时机
// ============================================================
const aiPredict = { result: null, loading: false, error: '', sig: '' };
function aiPredictSig() {
const r = histLowAnalysis.results;
if (!r.length) return '0';
return r.length + '_' + (r[0]?.appId || '') + '_' + (r[r.length - 1]?.appId || '');
}
// 构造 AI 价格预测 prompt
function buildAiPricePredictPrompt() {
const isZh = locale !== 'en';
const results = histLowAnalysis.results.filter(r => r.status !== 'no-data' && r.histData);
const stats = computeHistLowStats();
const cur = state.currency;
const curId = cur ? cur.id : 'CNY';
// 取前 15 款有史低数据的游戏(按距史低幅度升序)
const top = results.slice(0, 15);
const gameDataStr = top.map(r => {
const hist = r.histData;
const low = hist.lowest;
const maxCut = r.maxCut;
const distPct = low && low.price > 0 ? ((r.currentPrice - low.price) / low.price * 100).toFixed(0) : '?';
const trendStr = (hist.discounts || []).slice(0, 5).map(d => {
const dateStr = d.date ? new Date(d.date).toLocaleDateString('zh-CN') : '?';
return `-${d.cut}%@${dateStr}`;
}).join(', ');
return `- ${r.name}: 当前${fmtPrice(r.currentPrice)}${curId}(-${r.discountPct}%), 史低${low ? fmtPrice(low.price) + curId : '?'}(最大折扣-${maxCut}%), 距史低+${distPct}%, 折扣趋势: ${trendStr || '无'}`;
}).join('\n');
return `# 角色
你是一位 Steam 游戏价格分析专家和等等党顾问,擅长从历史价格走势中预测最佳入手时机。
# 任务
基于愿望单打折游戏的历史价格数据,从四个维度生成价格预测和入手建议。${isZh ? '全程使用中文回答。' : 'Answer entirely in English.'}
# 输入数据
- 打折游戏总数: ${stats.total} 款(已达史低 ${stats.atLow} / 接近史低 ${stats.nearLow} / 高于史低 ${stats.aboveLow} / 无数据 ${stats.noData})
- 货币: ${curId}
- 游戏明细(按距史低幅度升序,前 ${top.length} 款):
${gameDataStr || '无数据'}
# 输出格式
严格按以下 JSON 格式输出,不要输出任何其他文字:
{
"summary": "2-3句总体分析,概括当前愿望单打折游戏的价格水平和入手价值",
"buyNow": [{"name":"游戏名","reason":"1句理由:为什么现在入手","urgency":"high/medium/low"}],
"wait": [{"name":"游戏名","reason":"1句理由:为什么建议等待","expectedDrop":"预期降幅或下次打折时间推测"}],
"breakthrough": [{"name":"游戏名","prediction":"1句史低突破预测:是否可能在未来大促打破史低","probability":"high/medium/low"}]
}
每个数组最多 5 项。buyNow 优先选已达史低或接近史低的游戏,wait 优先选高于史低的游戏,breakthrough 选历史最大折扣高且当前未达史低的游戏。`;
}
// 触发 AI 价格预测
async function triggerAiPricePrediction() {
if (aiPredict.loading) return;
if (!histLowAnalysis.done || !histLowAnalysis.results.length) {
toast(t('aiPredictNoData'), 'warning');
return;
}
if (!aiStorage.getApiKey()) {
aiGlobalError(t('aiNotConfigured'));
openSettings();
return;
}
aiPredict.loading = true;
aiPredict.error = '';
aiPredict.result = null;
aiPredict.sig = aiPredictSig();
updateHistLowUI();
try {
const { content } = await callAiApi(buildAiPricePredictPrompt(), { temperature: 0.5, timeout: 120000, maxTokens: 3072 });
const result = safeParseAiJson(content);
if (!result.summary || !Array.isArray(result.buyNow)) throw new Error('AI 返回结构不完整');
aiPredict.result = result;
} catch (e) {
aiPredict.error = e.message || 'AI 预测失败';
aiGlobalError(aiPredict.error);
} finally {
aiPredict.loading = false;
updateHistLowUI();
}
}
// 渲染折扣趋势迷你图(SVG sparkline)
function renderDiscountTrendSparkline(discounts) {
const pts = (discounts || [])
.filter(d => d && d.date && d.cut > 0)
.map(d => ({ t: new Date(d.date).getTime(), cut: Number(d.cut) || 0 }))
.filter(p => !isNaN(p.t))
.sort((a, b) => a.t - b.t);
if (pts.length < 2) return '';
const W = 120, H = 32, PL = 2, PR = 2, PT = 3, PB = 3;
const iw = W - PL - PR, ih = H - PT - PB;
const t0 = pts[0].t, t1 = pts[pts.length - 1].t;
const span = Math.max(t1 - t0, 86400000);
const maxCut = Math.max(...pts.map(p => p.cut), 10);
const px = t => PL + ((t - t0) / span) * iw;
const py = c => PT + ih - (c / maxCut) * ih;
const linePath = pts.map((p, i) => `${i ? 'L' : 'M'}${px(p.t).toFixed(1)},${py(p.cut).toFixed(1)}`).join(' ');
const dots = pts.map(p => ``).join('');
return ``;
}
// 渲染史低分析内容(扫描结果 + AI 预测面板)
function renderHistLowContent() {
const discounted = getDiscountedItems();
const stats = computeHistLowStats();
const isScanning = histLowAnalysis.loading;
const hasResults = histLowAnalysis.results.length > 0;
// 头部:扫描按钮 + 进度
let headerHtml;
if (isScanning) {
headerHtml = `${ICONS.refresh}${t('histLowScanning', { done: histLowAnalysis.progress, total: histLowAnalysis.total })}
`;
} else {
const scanLabel = hasResults ? t('histLowScan') : t('histLowScan');
headerHtml = ``;
}
// KPI 卡片
let kpiHtml = '';
if (hasResults) {
const kpis = [
{ label: t('histLowSummary', { total: stats.total }), val: stats.total, icon: ICONS.history, cls: '' },
{ label: t('histLowAtLowCount'), val: stats.atLow, icon: ICONS.target, cls: 'green' },
{ label: t('histLowNearLowCount'), val: stats.nearLow, icon: ICONS.percent, cls: 'yellow' },
{ label: t('histLowAboveLowCount'), val: stats.aboveLow, icon: ICONS.trending, cls: 'red' },
];
kpiHtml = `${kpis.map(k => `
${k.icon}
${k.val}
${escapeHTML(k.label)}
`).join('')}
`;
}
// 游戏列表
let listHtml = '';
if (hasResults) {
const statusMap = {
'at-low': { cls: 'at-low', label: t('histLowAtLow') },
'near-low': { cls: 'near-low', label: t('histLowNearLow') },
'above-low': { cls: 'above-low', label: t('histLowAboveLow') },
'no-data': { cls: 'no-data', label: t('histLowNoData') },
};
const rows = histLowAnalysis.results.slice(0, 50).map(r => {
const st = statusMap[r.status] || statusMap['no-data'];
const lowStr = r.lowestPrice != null ? fmtPrice(r.lowestPrice) : '—';
const distStr = r.lowestPrice != null && r.lowestPrice > 0
? `+${((r.currentPrice - r.lowestPrice) / r.lowestPrice * 100).toFixed(0)}%`
: '—';
const dateStr = r.lowestDate ? new Date(r.lowestDate).toLocaleDateString('zh-CN', { year: '2-digit', month: '2-digit' }) : '—';
const trendSvg = r.histData?.discounts?.length >= 2 ? renderDiscountTrendSparkline(r.histData.discounts) : '';
const sourceBadge = r.source ? `${r.source === 'itad' ? 'ITAD' : r.source === 'cheapshark' ? 'CS' : 'Aug'}` : '';
return `
${escapeHTML(r.name)}
${fmtPrice(r.currentPrice)}
${lowStr}
${distStr}
${st.label}
-${r.discountPct}%
${r.maxCut ? `↓${r.maxCut}%` : ''}
${dateStr}
${sourceBadge}
${trendSvg ? `
${trendSvg}
` : ''}
`;
}).join('');
listHtml = `${rows}
`;
} else if (!isScanning) {
listHtml = `${ICONS.target}
${discounted.length ? t('histLowSectionSub') : t('noData')}
`;
}
// AI 预测面板
let aiHtml = '';
if (hasResults && !isScanning) {
const configured = !!aiStorage.getApiKey();
const stale = aiPredict.result && aiPredict.sig !== aiPredictSig();
const btnLabel = aiPredict.loading ? t('aiPredictAnalyzing') : (aiPredict.result && !stale ? t('aiPredictRetry') : t('aiPredictStart'));
let aiBody;
if (aiPredict.loading) {
aiBody = `${ICONS.refresh}${t('aiPredictAnalyzing')}
`;
} else if (aiPredict.error) {
aiBody = `⚠ ${escapeHTML(aiPredict.error)}
`;
} else if (aiPredict.result && !stale) {
const r = aiPredict.result;
const urgencyColors = { high: '#f87171', medium: '#fbbf24', low: '#4ade80' };
const probColors = { high: '#f87171', medium: '#fbbf24', low: '#94a3b8' };
aiBody = `
${ICONS.brain}
${escapeHTML(r.summary || '')}
${r.buyNow?.length ? `
${ICONS.zap}${t('aiPredictSecBuyNow')}
${r.buyNow.map(g => `
${escapeHTML(g.name)}${escapeHTML(g.reason || '')}${escapeHTML(g.urgency || '')}
`).join('')}
` : ''}
${r.wait?.length ? `
${ICONS.clock}${t('aiPredictSecWait')}
${r.wait.map(g => `
${escapeHTML(g.name)}${escapeHTML(g.reason || '')}${g.expectedDrop ? `${escapeHTML(g.expectedDrop)}` : ''}
`).join('')}
` : ''}
${r.breakthrough?.length ? `
${ICONS.sparkles}${t('aiPredictSecBreakthrough')}
${r.breakthrough.map(g => `
${escapeHTML(g.name)}${escapeHTML(g.prediction || '')}${escapeHTML(g.probability || '')}
`).join('')}
` : ''}
`;
} else {
aiBody = `${configured ? t('aiPredictHint') : t('aiNotConfigured')}
`;
}
aiHtml = ``;
}
return `${headerHtml}${kpiHtml}${listHtml}${aiHtml}`;
}
// 渲染史低分析区块(嵌入分析页)
function renderHistLowSection() {
const discounted = getDiscountedItems();
if (!discounted.length) return ''; // 无打折游戏时不显示
return `
${ICONS.target} ${t('histLowSection')}${t('histLowSectionSub')}
${renderHistLowContent()}
`;
}
// ---- 拥有状态:dynamicstore/userdata 批量获取(1次请求替代N次appuserdetails)----
// 参考 steam-family-analysis getMyGame():dynamicstore/userdata 不需要 API key,
// 登录态下返回 rgOwnedApps(用户拥有的所有 AppID 数组),1 次请求即可判定全部愿望单游戏拥有状态
const SWE_OWNERSHIP_KEY = 'swe_ownership_cache';
const SWE_OWNED_APPS_KEY = 'swe_owned_apps'; // v1.1.0:批量拥有列表缓存(与 family-analysis 可共用)
const SWE_OWNED_APPS_TTL = 30 * 60 * 1000; // 30 分钟(拥有列表变化频率低,但比单条7天短,更及时)
function loadOwnershipCache() {
try { const raw = GM_getValue(SWE_OWNERSHIP_KEY); if (raw && typeof raw === 'object') return raw; } catch (e) {}
return {};
}
function saveOwnershipCache(c) { try { GM_setValue(SWE_OWNERSHIP_KEY, c); } catch (e) {} }
// 批量拥有列表缓存:{ apps: [appId, appId, ...], ts: timestamp }
function loadOwnedAppsSet() {
try {
const raw = GM_getValue(SWE_OWNED_APPS_KEY);
if (raw && Array.isArray(raw.apps) && raw.ts && (Date.now() - raw.ts) < SWE_OWNED_APPS_TTL) {
return { set: new Set(raw.apps.map(String)), ts: raw.ts };
}
} catch (e) {}
return null;
}
function saveOwnedAppsSet(appIds) {
try { GM_setValue(SWE_OWNED_APPS_KEY, { apps: Array.from(appIds), ts: Date.now() }); } catch (e) {}
}
// 一次性获取用户拥有的所有 AppID(dynamicstore/userdata,不需要 API key)
// 参考 steam-family-analysis/steam-family-game-analysis-v1.62.js getMyGame()
let _ownedAppsPromise = null;
async function fetchOwnedAppsBatch() {
// 缓存命中
const cached = loadOwnedAppsSet();
if (cached) return cached.set;
// 防重复请求
if (_ownedAppsPromise) return _ownedAppsPromise;
_ownedAppsPromise = (async () => {
try {
const url = 'https://store.steampowered.com/dynamicstore/userdata/';
const data = await gmFetch(url, 'json');
if (data && Array.isArray(data.rgOwnedApps)) {
const set = new Set(data.rgOwnedApps.map(String));
saveOwnedAppsSet(data.rgOwnedApps);
return set;
}
return new Set();
} catch (e) {
console.warn('[SWE] dynamicstore/userdata failed:', e.message);
return new Set();
} finally {
_ownedAppsPromise = null;
}
})();
return _ownedAppsPromise;
}
const _ownershipPending = new Map();
async function checkOwnership(appId) {
const id = String(appId);
// 1. 优先从 dynamicstore 批量拥有列表中查找(O(1),无网络请求)
const ownedSet = await fetchOwnedAppsBatch();
if (ownedSet && ownedSet.size > 0) {
return ownedSet.has(id);
}
// 2. 回退:逐条缓存检查
const cache = loadOwnershipCache();
const c = cache[id];
if (c && c.ts && (Date.now() - c.ts) < CONFIG.OWNERSHIP_CACHE_TTL && typeof c.owned === 'boolean') return c.owned;
// 3. 最终回退:appuserdetails 逐个请求(仅在 dynamicstore 失败时使用)
if (_ownershipPending.has(id)) return _ownershipPending.get(id);
const p = (async () => {
await steamRateReserve(); // 预留位限速(Steam API 1s 间隔)
const url = 'https://store.steampowered.com/api/appuserdetails?appids=' + encodeURIComponent(id);
const data = await gmFetch(url, 'json');
const rec = data && data[id];
const owned = !!(rec && rec.success && rec.data && rec.data.owned);
cache[id] = { owned, ts: Date.now() };
saveOwnershipCache(cache);
return owned;
})();
_ownershipPending.set(id, p);
try { return await p; } catch (e) { return false; } finally { _ownershipPending.delete(id); }
}
// 批量检查拥有状态:优先 dynamicstore 一次全量,回退 appuserdetails 逐个
async function ensureOwnershipForItems(items, onProgress) {
const need = items.filter(it => it._ownedChecked !== true);
if (need.length === 0) { state.ownershipChecked = true; return; }
// 策略1:dynamicstore 批量获取(1次请求覆盖全部)
try {
const ownedSet = await fetchOwnedAppsBatch();
if (ownedSet && ownedSet.size > 0) {
let done = 0;
need.forEach(it => {
it.owned = ownedSet.has(String(it.appId));
it._ownedChecked = true;
done++;
if (onProgress && done % 10 === 0) onProgress(done, need.length);
});
if (onProgress) onProgress(need.length, need.length);
state.ownershipChecked = true;
return;
}
} catch (e) {
console.warn('[SWE] dynamicstore batch failed, falling back to appuserdetails:', e.message);
}
// 策略2回退:appuserdetails 逐个(3 worker / 1s 间隔)
let done = 0;
await runWorkerPool(need, CONFIG.STEAM_WORKERS, async (it) => {
try { it.owned = await checkOwnership(it.appId); } catch (e) { it.owned = false; }
it._ownedChecked = true;
done++;
if (onProgress) onProgress(done, need.length);
});
state.ownershipChecked = true;
}
// ---- 批量移除愿望单 ----
function getSessionId() {
try {
const uw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
if (uw.g_sessionID) return uw.g_sessionID;
if (uw.g_SessionID) return uw.g_SessionID;
} catch (e) {}
// document.cookie(sessionid 若非 HttpOnly 可读;GM_xmlhttpRequest 同源请求会自动带 HttpOnly cookie)
const m = document.cookie.match(/(?:^|;\s*)sessionid=([^;]+)/);
if (m) return decodeURIComponent(m[1]);
return '';
}
function gmPostForm(url, data) {
return new Promise((resolve, reject) => {
const form = Object.keys(data).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(data[k])).join('&');
GM_xmlhttpRequest({
method: 'POST', url, timeout: CONFIG.FETCH_TIMEOUT,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: form,
onload: r => { r.status === 200 ? resolve(r) : reject(new Error('HTTP ' + r.status)); },
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout'))
});
});
}
// Steam 移除愿望单 API:POST store.steampowered.com/api/wishlist/remove/,body 含 appid 与 sessionid
async function removeFromWishlist(appId) {
const sessionid = getSessionId();
if (!sessionid) throw new Error('sessionid unavailable');
const url = 'https://store.steampowered.com/api/wishlist/remove/';
const resp = await gmPostForm(url, { appid: String(appId), sessionid });
let ok = false;
try { const j = JSON.parse(resp.responseText); ok = !!(j && j.success !== false); } catch (e) { ok = resp.status === 200; }
if (!ok) throw new Error('remove failed');
return true;
}
let _batchConfirmModal = null;
function showBatchRemoveConfirm(targets) {
return new Promise(resolve => {
const modal = h('div', { class: 'swe-loading-overlay', style: { position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.65)', zIndex: '99999', display: 'flex', alignItems: 'center', justifyContent: 'center' } });
const listHtml = targets.map(tg => `${escapeHTML(tg.name)}#${escapeHTML(tg.id)}
`).join('');
modal.innerHTML = `
${ICONS.trash} ${t('batchRemoveTitle')}
${escHtml(t('batchRemoveConfirm', { count: targets.length }))}
${listHtml}
`;
document.body.appendChild(modal);
_batchConfirmModal = modal;
modal.querySelector('#swe-batch-cancel').addEventListener('click', () => { if (state.batchRemoving) return; modal.remove(); _batchConfirmModal = null; resolve(false); });
modal.querySelector('#swe-batch-ok').addEventListener('click', () => {
modal.querySelector('#swe-batch-ok').disabled = true;
modal.querySelector('#swe-batch-cancel').disabled = true;
modal.querySelector('#swe-batch-progress').style.display = 'block';
resolve(true); // 由 performBatchRemove 继续移除流程并更新进度
});
modal.addEventListener('click', e => { if (e.target === modal && !state.batchRemoving) { modal.remove(); _batchConfirmModal = null; resolve(false); } });
});
}
function updateBatchConfirmProgress(done, total, failed) {
if (!_batchConfirmModal) return;
const el = _batchConfirmModal.querySelector('#swe-batch-progress');
if (el) el.textContent = t('batchRemoving', { done, total }) + (failed > 0 ? ' · ' + t('batchRemoveFailed', { failed }) : '');
}
function closeBatchConfirm() { if (_batchConfirmModal) { _batchConfirmModal.remove(); _batchConfirmModal = null; } }
async function performBatchRemove() {
if (state.batchRemoving) return;
const selectedIds = Array.from(state.batchSelected);
if (selectedIds.length === 0) { toast(t('batchEmpty'), 'info'); return; }
const idToItem = new Map(state.items.map(it => [String(it.appId), it]));
const targets = selectedIds.map(id => ({ id, name: (idToItem.get(String(id)) || {}).name || ('App ' + id) }));
const confirmed = await showBatchRemoveConfirm(targets);
if (!confirmed) return;
state.batchRemoving = true;
let done = 0, failed = 0;
const okIds = [];
await runWorkerPool(targets, CONFIG.STEAM_WORKERS, async (tg) => {
try {
await steamRateReserve(); // 预留位限速(Steam API 1s 间隔)
await removeFromWishlist(tg.id);
okIds.push(tg.id);
} catch (e) { failed++; }
done++;
updateBatchConfirmProgress(done, targets.length, failed);
});
// 移除成功项:更新 state.items、选中集、DOM
if (okIds.length) {
const okSet = new Set(okIds.map(String));
state.items = state.items.filter(it => !okSet.has(String(it.appId)));
state.batchSelected = new Set([...state.batchSelected].filter(id => !okSet.has(String(id))));
}
state.batchRemoving = false;
closeBatchConfirm();
invalidateStats();
if (okIds.length) toast(t('batchRemoveDone', { done: okIds.length }), 'success');
if (failed > 0) toast(t('batchRemoveFailed', { failed }), 'error');
applyFilters();
}
// 价格历史懒加载:为当前可见的游戏项填充价格历史徽章
async function enrichPriceHistoryForVisible() {
if (activeTab !== 'wishlist') return;
const body = document.getElementById('swe-body');
if (!body) return;
const slots = body.querySelectorAll('.swe-ph-slot');
if (!slots.length) return;
loadPriceHistCache();
await loadExchangeRates(); // 先确保汇率就绪,避免徽章币种不一致
// 占位 loading
slots.forEach(s => { if (!s.dataset.filled) s.innerHTML = `${ICONS.trending}${t('phLoading')}`; });
const idToItem = new Map(state.filtered.map(it => [String(it.appId), it]));
const tasks = [];
slots.forEach(slot => {
const id = slot.getAttribute('data-appid');
const item = idToItem.get(String(id));
if (item) tasks.push({ slot, id, item });
});
await runWorkerPool(tasks, CONFIG.PRICEHIST_WORKERS, async (tk) => {
try {
const hist = await getPriceHistLow(tk.id);
const badge = computePriceHistBadge(tk.item, hist);
tk.slot.dataset.filled = '1';
// 无史低数据时隐藏标签(不显示 ph-none)
tk.slot.innerHTML = badge ? priceHistBadgeHtml(badge) : '';
} catch (e) {
tk.slot.dataset.filled = '1';
// 查询失败时也隐藏标签,避免界面冗余
tk.slot.innerHTML = '';
}
});
}
// ==================== 游戏中文名获取(参考 steam-friend-manager-1.1.2 fetchGameZhName) ====================
const SWE_GAME_NAME_KEY = 'swe_game_name_cache';
const SWE_NAME_CACHE_TTL = 30 * 864e5; // 30天过期
let _sweGameNameCache = null;
const _sweGameNamePending = new Map();
function isZhLocale() { return locale && locale.startsWith('zh'); }
function sweGameNameCacheLoad() {
if (_sweGameNameCache !== null) return _sweGameNameCache;
_sweGameNameCache = {};
try {
const raw = GM_getValue(SWE_GAME_NAME_KEY);
if (raw && typeof raw === 'object') _sweGameNameCache = raw;
} catch (e) {}
return _sweGameNameCache;
}
function sweGameNameCacheSave() {
try { GM_setValue(SWE_GAME_NAME_KEY, _sweGameNameCache || {}); } catch (e) {}
}
function fetchGameZhName(appid) {
if (!isZhLocale()) return Promise.resolve('');
const id = String(appid);
const cache = sweGameNameCacheLoad();
const cached = cache[id];
if (cached && cached.name && Date.now() - (cached.ts || 0) < SWE_NAME_CACHE_TTL) {
return Promise.resolve(cached.name);
}
if (_sweGameNamePending.has(id)) return _sweGameNamePending.get(id);
// 使用 gmFetchText + JSON.parse 替代 responseType:'json',提升兼容性(参考 v1.58 faFetchGameZhName)
const p = gmFetchText(`https://store.steampowered.com/api/appdetails?appids=${id}&l=schinese`)
.then(text => {
let name = '';
try {
const d = JSON.parse(text);
if (d && d[id] && d[id].success && d[id].data && d[id].data.name) name = d[id].data.name;
} catch (e) {}
if (name) {
cache[id] = { name, ts: Date.now() };
sweGameNameCacheSave();
}
return name;
})
.catch(() => '')
.finally(() => { _sweGameNamePending.delete(id); });
_sweGameNamePending.set(id, p);
return p;
}
// 异步加载中文名并更新 DOM 元素(参考 v1.58 faLoadGameZhName)
// el: 显示游戏名的元素;appid: 游戏ID;originalName: 当前显示的名称
function loadGameZhName(el, appid, originalName) {
if (!el || !appid) return;
fetchGameZhName(appid).then(zhName => {
if (zhName && zhName !== originalName) {
el.textContent = zhName;
el.title = `${zhName} (${originalName})`;
// 修复:占位名称(App xxxxxx / 空)异步加载到真实中文名后,
// 清除占位内联样式(灰色斜体),让 CSS 类正式样式生效。
// 仅对占位名称精确匹配生效,不影响已具备真实名称的元素。
if (!originalName || originalName === ('App ' + appid)) {
el.style.color = '';
el.style.fontStyle = '';
el.style.fontWeight = '';
}
}
});
}
// ============================================================
// SSR 数据提取
// ============================================================
function parseSSRQueryData() {
try {
const SSR = (typeof unsafeWindow !== 'undefined' ? unsafeWindow.SSR : null) || window.SSR || window.__SSR || null;
if (SSR?.renderContext?.queryData) {
let qd = SSR.renderContext.queryData;
if (typeof qd === 'string') qd = JSON.parse(qd);
if (qd && typeof qd.queryData === 'string') qd = JSON.parse(qd.queryData);
if (qd && Array.isArray(qd.queries)) return qd.queries;
}
if (SSR?.loaderData && Array.isArray(SSR.loaderData)) {
for (const raw of SSR.loaderData) {
let item = raw;
if (typeof item === 'string') item = JSON.parse(item);
if (item && typeof item.queryData === 'string') {
const qd = JSON.parse(item.queryData);
if (qd && Array.isArray(qd.queries)) return qd.queries;
}
}
}
const scripts = document.querySelectorAll('script');
for (const script of scripts) {
const text = script.textContent || '';
if (!text.includes('queryData')) continue;
const qd = extractQueryDataFromScriptText(text);
if (qd && Array.isArray(qd.queries)) return qd.queries;
}
return null;
} catch (e) { console.error('[SWE] parseSSRQueryData failed:', e); return null; }
}
function extractQueryDataFromScriptText(text) {
try {
let idx = 0;
while ((idx = text.indexOf('JSON.parse(', idx)) !== -1) {
const start = text.indexOf('"', idx + 11);
if (start === -1) { idx++; continue; }
let i = start + 1; let inEsc = false;
while (i < text.length) {
const c = text[i];
if (inEsc) inEsc = false;
else if (c === '\\') inEsc = true;
else if (c === '"') break;
i++;
}
const escaped = text.substring(start + 1, i).replace(/[\n\r]/g, '');
let decoded = '';
try { decoded = JSON.parse('"' + escaped + '"'); } catch (_) { idx = i + 1; continue; }
if (typeof decoded === 'string' && decoded.includes('queryData')) {
try {
const outerObj = JSON.parse(decoded);
if (outerObj && typeof outerObj.queryData === 'string') {
const qd = JSON.parse(outerObj.queryData);
if (qd && Array.isArray(qd.queries)) return qd;
}
} catch (_) {}
}
idx = i + 1;
}
} catch (e) { console.warn('[SWE] extractQueryDataFromScriptText failed:', e); }
return null;
}
function extractFromQueries(queries) {
const wishlistEntries = [];
const tagNameMap = {};
const storeItemCache = new Map();
const categoryNames = {}; // v1.1.0: 用户自定义愿望单类别 ID -> 名称
let steamIdLocal = null;
for (const query of queries) {
if (!query || !query.state || !query.state.data) continue;
const data = query.state.data;
const qKey = query.queryKey || [];
if (qKey[0] === 'WishlistSortedFiltered') {
const payload = Array.isArray(data) ? { items: data } : data;
if (payload && Array.isArray(payload.items) && payload.items.length > 0) {
wishlistEntries.push(...payload.items);
if (payload.steamid) steamIdLocal = payload.steamid;
}
}
if (qKey[0] === 'LocalizedTagNames' && typeof data === 'object' && !Array.isArray(data)) {
Object.assign(tagNameMap, data);
}
// v1.1.3: 提取用户自定义愿望单类别名称(SSR query key: WishlistCategories / WishlistUserCategories / WishlistUserCategoryList / WishlistCategoryList / UserCategories)
// Steam WebAPI 响应统一包一层 response 字段:{ response: { categories: [...] } }
// 同时兼容扁平结构:{ categories: [...] } / [...] 直接数组
const catQKey = qKey[0];
const isCatQuery = catQKey === 'WishlistCategories' || catQKey === 'WishlistUserCategories'
|| catQKey === 'WishlistUserCategoryList' || catQKey === 'WishlistCategoryList'
|| catQKey === 'UserCategories' || catQKey === 'WishlistCategoryData';
if (isCatQuery && data) {
const dUnwrap = (data && typeof data === 'object' && data.response && typeof data.response === 'object') ? data.response : data;
const cats = Array.isArray(dUnwrap.categories) ? dUnwrap.categories
: (Array.isArray(dUnwrap.wishlist_categories) ? dUnwrap.wishlist_categories
: (Array.isArray(dUnwrap) ? dUnwrap : []));
for (const c of cats) {
if (!c || typeof c !== 'object') continue;
const id = c.categoryid ?? c.category_id ?? c.id;
const name = c.category_name ?? c.name ?? c.label ?? c.display_name;
if (id != null && name) categoryNames[String(id)] = String(name);
}
// 兼容 { id: name } 或 { id: {category_name} } 映射
if (!Array.isArray(dUnwrap) && typeof dUnwrap === 'object' && !dUnwrap.categories && !dUnwrap.wishlist_categories) {
for (const [k, v] of Object.entries(dUnwrap)) {
if (k === 'success' || k === 'success_partial' || k === 'servernow') continue; // 跳过状态字段
if (typeof v === 'string' && v) categoryNames[String(k)] = v;
else if (v && typeof v === 'object') {
const name = v.category_name ?? v.name ?? v.label;
if (name) categoryNames[String(k)] = String(name);
}
}
}
}
if (qKey.length >= 3 && qKey[0] === 'StoreItem') {
const appIdKey = qKey[1];
const subKey = qKey[2];
const appId = parseInt(String(appIdKey).replace(/^app_/, ''), 10);
if (!appId || isNaN(appId)) continue;
if (!storeItemCache.has(appId)) storeItemCache.set(appId, { _appId: appId });
storeItemCache.get(appId)[subKey] = data;
}
}
const seen = new Set();
const uniqueEntries = [];
for (const e of wishlistEntries) {
if (!e || !e.appid || seen.has(e.appid)) continue;
seen.add(e.appid); uniqueEntries.push(e);
}
return { entries: uniqueEntries, tagNameMap, storeItemCache, categoryNames, steamId: steamIdLocal, source: 'SSR' };
}
function extractSSRData() {
const queries = parseSSRQueryData();
if (!queries) return null;
const data = extractFromQueries(queries);
if (data.entries.length === 0) return null;
console.log(`[SWE] SSR extraction: ${data.entries.length} items, ${data.storeItemCache.size} store details`);
return data;
}
function extractFromReactFiber() {
try {
const rootEl = document.querySelector('[data-react-nav-root]') || document.querySelector('#react_root') || document.querySelector('[id*="react"]');
if (!rootEl) return null;
const fiberKey = Object.keys(rootEl).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
if (!fiberKey) return null;
let fiber = rootEl[fiberKey];
let queryClient = null;
const visited = new Set();
const queue = [fiber];
let depth = 0;
while (queue.length > 0 && !queryClient && depth < 1000) {
const current = queue.shift();
if (!current || visited.has(current)) continue;
visited.add(current); depth++;
if (current.memoizedState?.memoizedState?.queryClient) { queryClient = current.memoizedState.memoizedState.queryClient; break; }
if (current.memoizedState?.queryClient) { queryClient = current.memoizedState.queryClient; break; }
if (current.pendingProps?.client?.queryClient) { queryClient = current.pendingProps.client.queryClient; break; }
if (current.child) queue.push(current.child);
if (current.sibling) queue.push(current.sibling);
if (current.return && visited.size < 10) queue.push(current.return);
}
if (!queryClient) return null;
const cache = queryClient.getQueryCache?.();
if (!cache) return null;
const queries = cache.getAll?.() || [];
const data = extractFromQueries(queries);
if (data.entries.length === 0) return null;
console.log(`[SWE] Fiber extraction: ${data.entries.length} items`);
return data;
} catch (e) { console.error('[SWE] Fiber extraction failed:', e); return null; }
}
function tryExtractLocalData() {
let data = extractSSRData();
if (data && data.entries.length > 0) return data;
data = extractFromReactFiber();
if (data && data.entries.length > 0) return data;
return null;
}
// ============================================================
// 缓存系统(localStorage + IndexedDB)
// ============================================================
let _db = null;
async function openCacheDB() {
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 clearCacheDB() {
try {
const db = await openCacheDB();
const tx = db.transaction(DB_STORE, 'readwrite');
tx.objectStore(DB_STORE).clear();
await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); });
} catch (e) { console.warn('[SWE] clear IndexedDB failed:', e); }
}
async function loadCachedItems() {
try {
const version = localStorage.getItem(CACHE_KEYS.VERSION);
if (version !== CONFIG.CACHE_VERSION) return { items: [], fps: new Map(), expired: true };
const metaRaw = localStorage.getItem(CACHE_KEYS.META);
const meta = metaRaw ? JSON.parse(metaRaw) : null;
if (!meta || (Date.now() - (meta.ts || 0) > CONFIG.CACHE_TTL)) return { items: [], fps: new Map(), expired: true };
const fpList = JSON.parse(localStorage.getItem(CACHE_KEYS.FP) || '[]');
if (!Array.isArray(fpList) || fpList.length === 0) return { items: [], fps: new Map(), expired: false };
const db = await openCacheDB();
const tx = db.transaction(DB_STORE, 'readonly');
const store = tx.objectStore(DB_STORE);
const map = await new Promise((resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => resolve(new Map((req.result || []).map(r => [r.fp, r.item])));
req.onerror = () => reject(req.error);
});
const items = [];
for (const fp of fpList) { const item = map.get(fp); if (item) items.push(item); }
console.log(`[SWE] loaded ${items.length} cached items`);
return { items, fps: new Map(), expired: false, meta };
} catch (e) { console.warn('[SWE] load cache failed:', e); return { items: [], fps: new Map(), expired: true }; }
}
async function saveCache(items, totalCount) {
try {
const fpList = [];
const rows = [];
for (const item of items) {
const fp = item._fp || hashFingerprint({ appid: item.appId, date_added: item.addedTimestamp, priority: item.priority, category_ids: item.categoryIds });
item._fp = fp;
fpList.push(fp);
rows.push({ fp, item });
}
const db = await openCacheDB();
const tx = db.transaction(DB_STORE, 'readwrite');
const store = tx.objectStore(DB_STORE);
for (const row of rows) store.put(row);
await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); });
localStorage.setItem(CACHE_KEYS.FP, JSON.stringify(fpList));
localStorage.setItem(CACHE_KEYS.META, JSON.stringify({ ts: Date.now(), total: totalCount, version: CONFIG.CACHE_VERSION }));
localStorage.setItem(CACHE_KEYS.VERSION, CONFIG.CACHE_VERSION);
console.log(`[SWE] saved ${rows.length} items to cache`);
} catch (e) { console.warn('[SWE] save cache failed:', e); }
}
// v1.1.0:清除愿望单增强相关 GM 缓存(价格历史/拥有状态/汇率)
function clearEnhancementCaches() {
try {
GM_setValue(SWE_PRICEHIST_KEY, {});
GM_setValue(SWE_FULLPH_KEY, {}); // v1.2.0:清除完整价格历史缓存
GM_setValue(SWE_OWNERSHIP_KEY, {});
GM_setValue(SWE_OWNED_APPS_KEY, null); // v1.1.0:清除 dynamicstore 批量拥有列表缓存
GM_setValue(SWE_FX_KEY, null);
// v1.1.9:清除封面 URL 持久化缓存(用户重置时一并清掉)
clearCoverUrlPersisted();
state.priceHistCache = {};
state.fullPhCache = {}; // v1.2.0:清除完整价格历史内存缓存
state.fxRates = null;
state.fxLoadedAt = 0;
state.ownershipChecked = false;
} catch (e) { console.warn('[SWE] clear enhancement cache failed:', e); }
}
// ============================================================
// 从 StoreItem 缓存构建条目
// ============================================================
function buildItemFromStoreCache(entry, storeItemCache, tagNameMap) {
const appId = entry.appid;
const priority = entry.priority ?? 0;
const dateAdded = entry.date_added || 0;
const categoryIds = Array.isArray(entry.category_ids) ? [...entry.category_ids] : [];
const storeData = storeItemCache ? storeItemCache.get(appId) : null;
const defaultInfo = storeData?.default_info || null;
const reviews = storeData?.include_reviews || null;
const release = storeData?.include_release || null;
const topTags = storeData?.top_tags || null;
const platforms = storeData?.include_platforms || null;
const assets = storeData?.include_assets || null;
const name = defaultInfo?.name || ('App ' + appId);
const type = defaultInfo?.type || 'game';
let finalPrice = 0, originalPrice = 0, discountPct = 0;
let formattedPrice = '', formattedOrigPrice = '';
let onSale = false;
if (defaultInfo?.best_purchase_option) {
const bpo = defaultInfo.best_purchase_option;
finalPrice = (Number(bpo.final_price_in_cents) || 0) / 100;
originalPrice = (Number(bpo.original_price_in_cents) || 0) / 100;
if (originalPrice === 0) originalPrice = finalPrice;
discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0);
formattedPrice = bpo.formatted_final_price || '';
formattedOrigPrice = bpo.formatted_original_price || '';
onSale = !!bpo.active_discounts?.length || discountPct > 0;
}
const isFree = defaultInfo?.best_purchase_option ? (finalPrice === 0 && discountPct === 0) : (defaultInfo?.is_free || false);
const isEarlyAccess = release?.is_early_access || false;
const releaseDateTimestamp = release?.steam_release_date || 0;
// v1.0.8: default_info.is_coming_soon 是权威字段(实测:未发售=true,已发售无此键),优先于时间戳推断
let isComingSoon = defaultInfo?.is_coming_soon === true;
let releaseDate = '';
if (releaseDateTimestamp > 0) {
const d = new Date(releaseDateTimestamp * 1000);
releaseDate = d.toISOString().slice(0, 10);
if (!isComingSoon && d.getTime() > Date.now()) isComingSoon = true;
}
if (!isComingSoon && release?.is_coming_soon) isComingSoon = true;
const reviewSummary = reviews?.summary_filtered || reviews?.summary_language_specific || null;
const reviewScore = reviewSummary?.review_score || '';
const reviewPercent = reviewSummary?.percent_positive || 0;
const reviewCount = reviewSummary?.review_count || reviewSummary?.total_reviews || 0;
const tags = [];
if (topTags && Array.isArray(topTags)) {
for (const tag of topTags) {
if (tag && tag.tagid) tags.push({ tagid: tag.tagid, name: tagNameMap[tag.tagid] || tag.name || ('Tag ' + tag.tagid), weight: tag.weight || 0 });
}
}
const plat = { windows: !!platforms?.windows, mac: !!platforms?.mac, linux: !!(platforms?.linux || platforms?.steamos_linux) };
const deckCompat = storeData?.deck_compatibility?.category || '';
const capsule = defaultInfo?.main_capsule || defaultInfo?.header || assets?.main_capsule || '';
return {
appId, name, priority, addedTimestamp: dateAdded,
addedDate: dateAdded > 0 ? new Date(dateAdded * 1000).toISOString().slice(0, 10) : '',
finalPrice: Math.round(finalPrice * 100) / 100,
originalPrice: Math.round(originalPrice * 100) / 100,
discountPct, formattedPrice, formattedOrigPrice, onSale,
reviewScore, reviewPercent, reviewCount,
isFree, isEarlyAccess, isComingSoon, releaseDate, type, tags, platforms: plat,
deckCompat, capsule, categoryIds, categoryNames: [],
storeUrl: `https://store.steampowered.com/app/${appId}/`,
// v1.0.8: default_info 存在即代表发售状态已知(is_coming_soon 缺省=已发售)
_fp: hashFingerprint(entry), _storeSubs: storeData || null, _releaseKnown: !!defaultInfo
};
}
function createPlaceholderItem(entry) {
const appId = entry.appid;
const priority = entry.priority ?? 0;
const dateAdded = entry.date_added || 0;
const categoryIds = Array.isArray(entry.category_ids) ? [...entry.category_ids] : [];
return {
appId, name: 'App ' + appId, priority, addedTimestamp: dateAdded,
addedDate: dateAdded > 0 ? new Date(dateAdded * 1000).toISOString().slice(0, 10) : '',
finalPrice: 0, originalPrice: 0, discountPct: 0,
formattedPrice: '', formattedOrigPrice: '', onSale: false,
reviewScore: '', reviewPercent: 0, reviewCount: 0,
isFree: false, isEarlyAccess: false, isComingSoon: false,
releaseDate: '', type: 'game', tags: [],
platforms: { windows: false, mac: false, linux: false },
deckCompat: '', capsule: '', categoryIds, categoryNames: [],
storeUrl: `https://store.steampowered.com/app/${appId}/`,
_fp: hashFingerprint(entry), _storeSubs: null, _releaseKnown: false
};
}
// ============================================================
// 进度条
// ============================================================
function updateProgress(percent, statusText) {
const overlay = document.getElementById('swe-progress-overlay');
if (overlay) overlay.classList.add('show');
const fill = document.querySelector('#swe-progress-overlay .swe-progress-fill');
if (fill) fill.style.width = Math.min(100, Math.max(0, percent)) + '%';
const text = document.querySelector('#swe-progress-overlay .swe-progress-text');
if (text && statusText != null) text.textContent = statusText;
}
function hideProgress() {
const overlay = document.getElementById('swe-progress-overlay');
if (overlay) overlay.classList.remove('show');
}
// ============================================================
// 主加载函数
// ============================================================
// v1.1.7+: 异步并发优化
// - SSR 类别名应用与主加载流程**并发**(fire-and-forget)
// - token 预取在 init 阶段已启动,类别获取走 _fetchAndApplyFromApi 时直接复用
// - 主流程不阻塞:toast / progress / render 在 1 次 microtask 内全部就位
async function loadAllWishlist() {
if (state.loading) return;
state.loading = true;
stopLoading = false;
updateLoadBtn();
// v1.1.7+: 异步并发——SSR 类别名应用与主流程并行
// 不 await,fire-and-forget;renderActiveTab 在 state.items 设置后会重新触发
const ssrCategoryPromise = (async () => {
try {
const localData = tryExtractLocalData();
if (localData && localData.categoryNames && Object.keys(localData.categoryNames).length > 0) {
applyCategoryNames(localData.categoryNames, { skipWrite: true });
}
} catch (e) { /* ignore */ }
})();
try {
const localData = tryExtractLocalData();
let entries = localData ? localData.entries : [];
let tagNameMap = localData ? localData.tagNameMap : {};
let storeItemCache = localData ? localData.storeItemCache : new Map();
// v1.1.0: 应用 SSR 提取的愿望单类别名称到 state(同步路径,与 ssrCategoryPromise 结果等价)
// v1.1.4: SSR 路径不写缓存(仅 state),后续 fetchWishlistCategoryNames 会写
// v1.1.7+: 这一步保留兼容,但实际数据已被并发的 ssrCategoryPromise 写入
if (localData && localData.categoryNames && Object.keys(localData.categoryNames).length > 0) {
applyCategoryNames(localData.categoryNames, { skipWrite: true });
}
// 等待并发任务结束(实际几乎已同步完成,只是保险)
await ssrCategoryPromise;
if (entries.length === 0) {
updateProgress(0, t('loadStatusFetch').replace('{page}', '0').replace('{loaded}', '0').replace('{total}', '0'));
entries = await fetchWishlistEntries();
}
if (entries.length === 0) {
toast(t('exportNoData'), 'error');
state.loading = false;
updateLoadBtn();
hideProgress();
return;
}
updateProgress(5, t('loadStatusCache'));
const cached = await loadCachedItems();
let allItems = [];
const newOrChangedEntries = [];
const cachedMap = new Map();
for (const item of cached.items) cachedMap.set(item.appId, item);
for (const entry of entries) {
const fp = hashFingerprint(entry);
const cachedItem = cachedMap.get(entry.appid);
// v1.0.8: 旧缓存条目缺少 _releaseKnown 标记(发售状态可能是旧 bug 写入的错误值),强制重建一次
if (cachedItem && cachedItem._fp === fp && !cached.expired && cachedItem._releaseKnown === true) {
allItems.push(cachedItem);
} else {
newOrChangedEntries.push(entry);
}
}
updateProgress(10, t('loadStatusEnrich').replace('{done}', '0').replace('{total}', newOrChangedEntries.length));
const built = [];
for (let i = 0; i < newOrChangedEntries.length; i++) {
const entry = newOrChangedEntries[i];
let item = null;
if (storeItemCache && storeItemCache.has(entry.appid)) {
item = buildItemFromStoreCache(entry, storeItemCache, tagNameMap);
}
if (!item) {
item = createPlaceholderItem(entry);
if (entry.name || entry.subs || entry.capsule || entry.best_purchase_option) {
applyApiInfoToItem(item, entry, tagNameMap);
item._apiEnriched = true;
}
}
built.push(item);
if (i % 10 === 0) {
updateProgress(10 + (i / Math.max(1, newOrChangedEntries.length)) * 30, t('loadStatusEnrich').replace('{done}', i).replace('{total}', newOrChangedEntries.length));
await sleep(0);
}
}
allItems.push(...built);
// v1.0.8: 发售状态未知(_releaseKnown=false)的条目也必须进入补全流程,
// 否则 wishlistdata 缺 is_coming_soon 或 SSR 失败走纯 API 路径时,"即将推出"会被误判为"已发售"
const needEnrich = allItems.filter(item =>
(!item._apiEnriched && (!item._storeSubs || item.name.startsWith('App ')))
|| !item._releaseKnown
);
if (needEnrich.length > 0) {
updateProgress(45, t('loadStatusEnrich').replace('{done}', '0').replace('{total}', needEnrich.length));
await enrichItemsFromAPI(needEnrich, tagNameMap);
}
updateCurrencyFromItems(allItems);
await saveCache(allItems, allItems.length);
state.items = allItems;
state.allLoaded = true;
state.ssrLoaded = !!localData;
invalidateStats();
// v1.2.0:数据重载时重置史低分析状态(提示用户重新扫描)
histLowAnalysis.done = false;
histLowAnalysis.results = [];
aiPredict.result = null;
applyFilters();
updateProgress(100, t('loadStatusDone'));
toast(t('loadingDone') + ` (${allItems.length} ${t('games')})`, 'success');
// v1.1.0: 若 SSR 未提取到类别名称,异步走 API 兜底获取(不阻塞主流程)
// v1.1.7+: 并发模式:直接 fire-and-forget,token 已在 init 阶段预取
if (Object.keys(state.wishlistCategoryNames).length === 0) {
Promise.resolve().then(() => fetchWishlistCategoryNames()).catch(() => {});
} else {
// SSR 已应用 → 同步到缓存 + 启动后台校验
try { _writeCurrentToCache(steamId); } catch (e) { /* ignore */ }
try { _scheduleBackgroundRefresh(steamId); } catch (e) { /* ignore */ }
}
} catch (e) {
console.error('[SWE] loadAllWishlist failed:', e);
toast(t('exportFailed') + ': ' + e.message, 'error');
} finally {
state.loading = false;
updateLoadBtn();
setTimeout(hideProgress, 800);
renderActiveTab();
}
}
// ============================================================
// wishlistdata API 分页获取
// ============================================================
// v1.0.9: 并发波次拉取全部分页(每波 4 页并行),替代串行逐页,大幅缩短等待。
// 单页失败/为空即视为已到末页(与原 enrich 逻辑一致);返回按 appid 去重后的合并条目。
async function fetchAllWishlistDataPages(onWave) {
const baseUrl = getWishlistApiBase();
const all = [];
const seen = new Set();
const WAVE = 4;
let page = 0;
let done = false;
while (page < CONFIG.MAX_PAGES && !done && !stopLoading) {
const batch = [];
for (let i = 0; i < WAVE && page + i < CONFIG.MAX_PAGES; i++) batch.push(page + i);
const results = await Promise.all(batch.map(p =>
gmFetch(`${baseUrl}?p=${p}`).then(d => ({ p, d })).catch(() => ({ p, d: null }))
));
results.sort((a, b) => a.p - b.p);
for (const { d } of results) {
if (done) break;
if (!d || (typeof d === 'object' && Object.keys(d).length === 0)) { done = true; break; }
const entries = Array.isArray(d)
? d.filter(i => i && i.appid)
: Object.entries(d).map(([k, v]) => ({ appid: parseInt(k, 10), ...v })).filter(i => i.appid);
if (entries.length === 0) { done = true; break; }
for (const e of entries) {
if (seen.has(e.appid)) continue;
seen.add(e.appid);
all.push(e);
}
}
page += WAVE;
if (onWave) onWave(page, all.length);
}
return all;
}
async function fetchWishlistEntries() {
return fetchAllWishlistDataPages((page, loaded) => {
updateProgress(Math.min(40, (page + 1) * 5), t('loadStatusFetch').replace('{page}', page).replace('{loaded}', loaded).replace('{total}', loaded));
});
}
// ============================================================
// API 批量补充详情
// ============================================================
async function enrichItemsFromAPI(items, tagNameMap) {
if (items.length === 0) return;
const itemMap = new Map(items.map(i => [i.appId, i]));
const needSet = new Set(itemMap.keys());
// v1.0.9 三段式:① wishlistdata 并发分页匹配 → ② GetItems 批量并发补全 → ③ appdetails 兜底残余
// 阶段①:并发拉取全部分页后一次性匹配(原串行逐页等待是"补充详情"慢的主因之一)
const entries = await fetchAllWishlistDataPages((_page, loaded) => {
updateProgress(45 + Math.min(13, (loaded / Math.max(1, items.length)) * 13),
t('loadStatusEnrich').replace('{done}', Math.min(loaded, items.length)).replace('{total}', items.length));
});
let enrichedCount = 0;
for (const info of entries) {
const item = itemMap.get(info.appid);
if (!item || !needSet.has(info.appid)) continue;
applyApiInfoToItem(item, info, tagNameMap);
needSet.delete(info.appid);
enrichedCount++;
}
updateProgress(58, t('loadStatusEnrich').replace('{done}', enrichedCount).replace('{total}', items.length));
// v1.0.8: 发售状态未知的也需补全(appdetails/GetItems 的 coming_soon 为权威判定),覆盖预购等有价格的未发售游戏
const stillNeed = items.filter(i => (!i._storeSubs && i.name.startsWith('App ')) || !i.formattedPrice || !i._releaseKnown);
if (stillNeed.length === 0 || stopLoading) return;
// 阶段②:GetItems 批量补全(100 个/批、3 路并发,几百条几秒完成)
await enrichFromStoreBrowse(stillNeed);
// 阶段③:appdetails 仅兜底批量接口仍缺失的少量残余
const leftovers = stillNeed.filter(i => i.name.startsWith('App ') || (!i.formattedPrice && !i.isFree) || !i._releaseKnown);
if (leftovers.length > 0 && !stopLoading) {
await enrichFromAppDetails(leftovers, tagNameMap);
}
}
function applyApiInfoToItem(item, info, tagNameMap) {
if (!item) return;
if (info.name && (item.name.startsWith('App ') || isZhLocale())) item.name = info.name;
item.priority = info.priority ?? item.priority;
if (info.added) item.addedTimestamp = info.added;
if (item.addedTimestamp > 0) item.addedDate = new Date(item.addedTimestamp * 1000).toISOString().slice(0, 10);
const hasPriceInfo = (info.subs && info.subs.length > 0) || !!info.best_purchase_option;
if (info.subs && info.subs.length > 0) {
// v1.0.8: 旧版只读 subs[0],会漏掉折扣在非首个购买选项的游戏;改为取折扣最大的 sub
let sub = info.subs[0];
for (const s of info.subs) {
if ((Number(s.discount_pct) || 0) > (Number(sub.discount_pct) || 0)) sub = s;
}
item.finalPrice = (Number(sub.price) || 0) / 100;
item.discountPct = Number(sub.discount_pct) || 0;
if (item.discountPct > 0 && item.discountPct < 100) {
item.originalPrice = item.finalPrice / (1 - item.discountPct / 100);
} else {
item.originalPrice = item.finalPrice;
}
item.formattedPrice = sub.formatted_price || (item.finalPrice > 0 ? `${state.currency.symbol}${item.finalPrice.toFixed(2)}` : '');
}
if (info.best_purchase_option) {
const bpo = info.best_purchase_option;
item.finalPrice = (Number(bpo.final_price_in_cents) || 0) / 100;
item.originalPrice = (Number(bpo.original_price_in_cents) || 0) / 100;
if (item.originalPrice === 0) item.originalPrice = item.finalPrice;
item.discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0);
item.formattedPrice = bpo.formatted_final_price || '';
item.formattedOrigPrice = bpo.formatted_original_price || '';
}
if (!Number.isFinite(item.finalPrice) || item.finalPrice < 0) item.finalPrice = 0;
if (!Number.isFinite(item.originalPrice) || item.originalPrice < 0) item.originalPrice = item.finalPrice;
if (!Number.isFinite(item.discountPct) || item.discountPct < 0 || item.discountPct > 100) item.discountPct = 0;
item.isFree = !!info.is_free || (hasPriceInfo && item.finalPrice === 0 && item.discountPct === 0);
if (typeof info.is_early_access !== 'undefined') item.isEarlyAccess = !!info.is_early_access;
// v1.0.8: 发售状态权威化——is_coming_soon 字段 / release_date 未来时间戳 /
// release_string 未发售措辞(即将推出/Coming Soon/TBA/Q3 等)多源判定,且可回退 true→false(游戏已发售时纠正旧缓存)
const rdTs = Number(info.release_date) || 0;
const relStr = typeof info.release_string === 'string' ? info.release_string : '';
let soonFlag = info.is_coming_soon === true;
if (!soonFlag && rdTs * 1000 > Date.now()) soonFlag = true;
if (!soonFlag && relStr && /即将推出|即将宣布|coming soon|to be announced|\btba\b|\btbd\b|\bq[1-4]\b|summer|winter|spring|fall|autumn|^\s*\d{4}\s*$|年第[一二三四1-4]季度|[春夏秋冬]季/i.test(relStr)) soonFlag = true;
const relKnown = typeof info.is_coming_soon !== 'undefined' || rdTs > 0 || soonFlag;
if (relKnown) {
item.isComingSoon = soonFlag;
item._releaseKnown = true;
if (rdTs > 0) item.releaseDate = new Date(rdTs * 1000).toISOString().slice(0, 10);
}
if (hasPriceInfo) item.onSale = item.discountPct > 0;
item.type = info.type || item.type || 'game';
item.reviewScore = info.review_score || item.reviewScore || '';
item.capsule = info.capsule || info.header || item.capsule || '';
// v1.0.8: 仅在无标签时写入,避免 wishlistdata 的字符串标签覆盖 SSR 的完整 top_tags
if (info.tags && Array.isArray(info.tags) && (!item.tags || item.tags.length === 0)) {
item.tags = info.tags.map(tag => typeof tag === 'string'
? { tagid: 0, name: tag, weight: 0 }
: { tagid: tag.tagid, name: tagNameMap[tag.tagid] || tag.name || ('Tag ' + tag.tagid), weight: tag.weight || 0 });
}
// v1.1.0: 更新用户自定义愿望单类别 ID 列表(wishlistdata 的 category_ids 字段)
if (Array.isArray(info.category_ids)) {
item.categoryIds = info.category_ids.map(id => String(id)).filter(s => s && s !== '0');
}
item.finalPrice = Math.round(item.finalPrice * 100) / 100;
item.originalPrice = Math.round(item.originalPrice * 100) / 100;
}
// v1.0.9: IStoreBrowseService/GetItems 批量补全(实测匿名可用,单批 100 个 appid、3 路并发)
// 一次请求即可拿到价格/折扣/发售状态/平台,替代绝大多数 appdetails 单条请求
async function enrichFromStoreBrowse(items) {
const cc = state.currency?.cc || 'CN';
const l = STEAM_LANG_MAP[locale] || 'schinese';
const CHUNK = 100, CONCURRENCY = 3;
const chunks = [];
for (let i = 0; i < items.length; i += CHUNK) chunks.push(items.slice(i, i + CHUNK));
let processed = 0;
let qIdx = 0;
const total = items.length;
async function worker() {
while (qIdx < chunks.length && !stopLoading) {
const chunk = chunks[qIdx++];
const input = {
ids: chunk.map(i => ({ appid: i.appId })),
context: { language: l, country_code: cc, steam_realm: 1 },
data_request: { include_release: true, include_all_purchase_options: true, include_platforms: true }
};
try {
const url = 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=' + encodeURIComponent(JSON.stringify(input));
const resp = await gmFetch(url);
const storeItems = resp?.response?.store_items || [];
const byId = new Map(storeItems.map(si => [si.appid, si]));
for (const item of chunk) {
const si = byId.get(item.appId);
if (si && (si.success === 1 || si.success === true)) applyStoreBrowseToItem(item, si);
processed++;
}
} catch (e) {
console.warn('[SWE] GetItems batch failed:', e);
processed += chunk.length;
}
updateProgress(60 + (processed / Math.max(1, total)) * 25, t('loadStatusEnrich').replace('{done}', processed).replace('{total}', total));
await sleep(CONFIG.API_DELAY);
}
}
const workers = [];
for (let i = 0; i < CONCURRENCY; i++) workers.push(worker());
await Promise.all(workers);
}
function applyStoreBrowseToItem(item, si) {
if (!item || !si) return;
if (si.name && (item.name.startsWith('App ') || isZhLocale())) item.name = si.name;
// 注意:GetItems 的 type 是数值枚举(0=game),不覆盖字符串类型字段
if (typeof si.is_free !== 'undefined') item.isFree = !!si.is_free;
// 价格与折扣(best_purchase_option 结构与页面 SSR default_info 一致)
const bpo = si.best_purchase_option;
if (bpo) {
item.finalPrice = (Number(bpo.final_price_in_cents) || 0) / 100;
item.originalPrice = (Number(bpo.original_price_in_cents) || 0) / 100;
if (item.originalPrice === 0) item.originalPrice = item.finalPrice;
item.discountPct = Number(bpo.discount_pct || bpo.bundle_discount_pct || 0);
item.formattedPrice = bpo.formatted_final_price || item.formattedPrice || '';
item.formattedOrigPrice = bpo.formatted_original_price || item.formattedOrigPrice || '';
item.onSale = item.discountPct > 0 || !!(bpo.active_discounts && bpo.active_discounts.length);
if (item.originalPrice === item.finalPrice && item.discountPct > 0 && item.discountPct < 100) {
item.originalPrice = item.finalPrice / (1 - item.discountPct / 100);
}
if (item.finalPrice === 0 && item.discountPct === 0) item.isFree = true;
}
// 发售状态:release 子对象与顶层 is_coming_soon 互为冗余(实测未发售=true,已发售无此键)
const rel = si.release || null;
let soon = si.is_coming_soon === true || rel?.is_coming_soon === true;
const rdTs = Number(rel?.steam_release_date) || 0;
if (!soon && rdTs * 1000 > Date.now()) soon = true;
if (rel || typeof si.is_coming_soon !== 'undefined') {
item.isComingSoon = soon;
item._releaseKnown = true;
if (rdTs > 0) item.releaseDate = new Date(rdTs * 1000).toISOString().slice(0, 10);
}
// 平台
if (si.platforms) {
item.platforms = { windows: !!si.platforms.windows, mac: !!si.platforms.mac, linux: !!(si.platforms.linux || si.platforms.steamos_linux) };
}
// 数值规整
if (!Number.isFinite(item.finalPrice) || item.finalPrice < 0) item.finalPrice = 0;
if (!Number.isFinite(item.originalPrice) || item.originalPrice < 0) item.originalPrice = item.finalPrice;
if (!Number.isFinite(item.discountPct) || item.discountPct < 0 || item.discountPct > 100) item.discountPct = 0;
item.finalPrice = Math.round(item.finalPrice * 100) / 100;
item.originalPrice = Math.round(item.originalPrice * 100) / 100;
}
async function enrichFromAppDetails(items, tagNameMap) {
// v2.3.26: Steam appdetails API 不支持逗号分隔的批量 appid 请求
// 必须单独请求每个 appId,使用 3 个并发 worker + 请求间隔控制
const cc = state.currency?.cc || 'CN';
const l = STEAM_LANG_MAP[locale] || 'schinese';
let done = 0;
let processed = 0;
let queueIdx = 0;
const total = items.length;
async function worker() {
while (queueIdx < total && !stopLoading) {
const idx = queueIdx++;
const item = items[idx];
try {
const url = `https://store.steampowered.com/api/appdetails?appids=${item.appId}&cc=${cc}&l=${l}`;
const data = await gmFetch(url);
if (data && data[item.appId] && data[item.appId].success && data[item.appId].data) {
applyAppDetailsToItem(item, data[item.appId].data, tagNameMap);
done++;
}
} catch (e) {
console.warn(`[SWE] appdetails failed for ${item.appId}:`, e);
}
// v1.0.9: 进度按已处理数(成功+失败)推进,限流/失败时进度条不再卡死
processed++;
updateProgress(85 + (processed / Math.max(1, total)) * 10, t('loadStatusEnrich').replace('{done}', processed).replace('{total}', total));
await sleep(CONFIG.APPDETAILS_DELAY);
}
}
// 启动 3 个并发 worker
const workers = [];
for (let i = 0; i < 3; i++) workers.push(worker());
await Promise.all(workers);
}
function applyAppDetailsToItem(item, data, tagNameMap) {
if (!item || !data) return;
if (data.name && (item.name.startsWith('App ') || isZhLocale())) item.name = data.name;
item.type = data.type || item.type || 'game';
if (typeof data.is_free !== 'undefined') item.isFree = !!data.is_free;
if (typeof data.is_early_access !== 'undefined') item.isEarlyAccess = !!data.is_early_access;
// v1.0.8: appdetails 的 release_date.coming_soon 为权威判定(可回退 true→false,纠正旧缓存)
if (data.release_date && typeof data.release_date.coming_soon !== 'undefined') {
item.isComingSoon = !!data.release_date.coming_soon;
item._releaseKnown = true;
if (data.release_date.date) item.releaseDate = data.release_date.date;
}
if (data.price_overview) {
const po = data.price_overview;
item.finalPrice = (po.final || po.initial || 0) / 100;
item.originalPrice = (po.initial || po.final || 0) / 100;
item.discountPct = po.discount_percent || 0;
item.formattedPrice = po.final_formatted || '';
item.formattedOrigPrice = po.initial_formatted || '';
item.onSale = item.discountPct > 0;
}
if (data.metacritic?.score) item.reviewScore = data.metacritic.score;
if (data.categories && Array.isArray(data.categories)) item.categoryNames = data.categories.map(c => c.description);
if (data.genres && Array.isArray(data.genres) && (!item.tags || item.tags.length === 0)) {
item.tags = data.genres.map(g => ({ tagid: g.id, name: g.description, weight: 0 }));
}
if (data.platforms) {
item.platforms = { windows: !!data.platforms.windows, mac: !!data.platforms.mac, linux: !!(data.platforms.linux || data.platforms.steamos_linux) };
}
if (data.header_image) item.capsule = data.header_image;
// v2.2.27:同步写入中文名缓存,避免 loadGameZhName 对已补全的游戏重复请求 appdetails
if (isZhLocale() && data.name) {
const nc = sweGameNameCacheLoad();
nc[String(item.appId)] = { name: data.name, ts: Date.now() };
try { sweGameNameCacheSave(); } catch (e2) {}
}
}
// ============================================================
// v1.0.8 后台动态字段刷新(缓存恢复后自动校正价格/折扣/发售状态)
// 缓存指纹不含价格与发售状态,TTL 内打折开始/结束、游戏发售都不会触发重取,
// 因此在后台用 wishlistdata 静默刷新动态字段,并回写缓存、重算统计
// ============================================================
let _dynamicRefreshing = false;
async function refreshDynamicFields() {
if (_dynamicRefreshing || state.loading) return;
const items = state.items;
if (!items || items.length === 0) return;
_dynamicRefreshing = true;
try {
// v1.0.9: 复用并发波次拉页(4 页/波),后台刷新更快完成
const entries = await fetchAllWishlistDataPages();
const itemMap = new Map(items.map(i => [i.appId, i]));
let touched = 0;
for (const info of entries) {
const item = itemMap.get(info.appid);
if (!item) continue;
applyApiInfoToItem(item, info, {});
item._apiEnriched = true;
touched++;
}
if (touched > 0) {
console.log(`[SWE] dynamic refresh updated ${touched} items`);
invalidateStats();
saveCache(items, items.length);
renderActiveTab();
}
} finally {
_dynamicRefreshing = false;
}
}
// ============================================================
// v1.1.0 用户自定义愿望单类别名称获取
// SSR 提取优先 → API 兜底(IWishlistService/GetWishlistCategories)
// 静默异步刷新,完成后 invalidateStats + 局部刷新分析页
// v1.1.1: 修复 access_token 获取——g_sessionID 不是 webapi_token,
// 需从 #application_config[data-store_user_config] 提取
// v1.1.4: 持久化 GM 缓存(按 steamId 隔离 + 内容 hash)
// 冷启动:缓存 > SSR > API(不命中才发请求)
// 热路径:后台静默 API 校验 + hash 比对,仅在变更时更新
// ============================================================
const SWE_CATNAMES_KEY = 'swe_wishlist_catnames_v2';
const SWE_CATNAMES_TTL_MS = 24 * 60 * 60 * 1000; // 24h 软过期,强制走一次校验
const SWE_CATNAMES_HARD_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d 硬过期,必须重取
let _cachedWebApiToken = null;
// 计算内容 hash(简化版 FNV-1a),仅用于比对是否有变化
function _hashCategoryNames(map) {
if (!map || typeof map !== 'object') return '';
const keys = Object.keys(map).sort();
let h = 0x811c9dc5;
const str = keys.map(k => `${k}=${map[k]}`).join('|');
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h.toString(36) + ':' + keys.length;
}
// 读取完整缓存容器
function _loadCatNamesCache() {
try {
const raw = GM_getValue(SWE_CATNAMES_KEY);
if (raw && typeof raw === 'object' && raw.bySteamId && typeof raw.bySteamId === 'object') {
return raw;
}
} catch (e) { /* ignore */ }
return { v: 2, bySteamId: {} };
}
// 保存完整缓存容器(去重 + 最多保留 5 个 steamId)
function _saveCatNamesCache(container) {
try {
const ids = Object.keys(container.bySteamId || {});
// LRU 裁剪:超过 5 个时按 ts 删旧
if (ids.length > 5) {
ids.sort((a, b) => (container.bySteamId[a].ts || 0) - (container.bySteamId[b].ts || 0));
while (ids.length > 5) {
delete container.bySteamId[ids.shift()];
}
}
container.v = 2;
GM_setValue(SWE_CATNAMES_KEY, container);
} catch (e) { /* ignore */ }
}
// 读取当前 steamId 的缓存条目(找不到返回 null)
function loadCachedCategoryNames(steamId) {
if (!steamId) return null;
const c = _loadCatNamesCache();
return c.bySteamId[steamId] || null;
}
// 判断是否需要后台校验(软过期)
function _isCacheStale(entry) {
if (!entry || !entry.ts) return true;
return (Date.now() - entry.ts) > SWE_CATNAMES_TTL_MS;
}
// 判断是否硬过期(必须重新拉取)
function _isCacheHardExpired(entry) {
if (!entry || !entry.ts) return true;
return (Date.now() - entry.ts) > SWE_CATNAMES_HARD_TTL_MS;
}
// 应用类别名映射到 state + 写缓存
// options.force=true 强制更新;options.skipWrite=true 不写缓存(用于 SSR 应用时)
function applyCategoryNames(map, options) {
const opts = options || {};
if (!map || Object.keys(map).length === 0) return false;
const cur = state.wishlistCategoryNames || {};
// 检测是否有变化(避免无效 invalidate)
const newHash = _hashCategoryNames(map);
const curHash = _hashCategoryNames(cur);
const changed = newHash !== curHash;
state.wishlistCategoryNames = { ...cur, ...map };
if (opts.skipWrite) {
// 仅 state 更新,不写缓存(SSR 路径,缓存由后续后台校验决定)
if (changed) invalidateStats();
return true;
}
// 写缓存(按 steamId 隔离)
const sid = opts.steamId || (typeof steamId !== 'undefined' ? steamId : '') || '';
if (sid) {
const container = _loadCatNamesCache();
container.bySteamId[sid] = {
names: { ...map },
ts: Date.now(),
hash: newHash
};
_saveCatNamesCache(container);
}
if (changed) invalidateStats();
return true;
}
// v1.1.1: 同步获取 webapi_token(从页面 application_config)
function getAccessTokenSync() {
try {
const appConfig = document.getElementById('application_config');
if (appConfig) {
const storeConfig = JSON.parse(appConfig.getAttribute('data-store_user_config') || '{}');
if (storeConfig.webapi_token) return storeConfig.webapi_token;
}
} catch (e) { /* ignore */ }
try {
const m = document.documentElement.innerHTML.match(/"webapi_token"\s*:\s*"([^"]+)"/);
if (m && m[1]) return m[1];
} catch (e) { /* ignore */ }
return null;
}
// v1.1.1: 从 store 首页异步获取 webapi_token(兜底)
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); }
});
});
}
// v1.1.1: 组合获取 access_token(同步优先 → store 首页兜底)
// v1.1.7+: 复用 _prefetchWebApiTokenAsync 的并发结果,避免与 init 阶段的预取竞争
async function getAccessToken() {
// 0) 已缓存(init 阶段同步/异步预取的产物)
if (_cachedWebApiToken) return _cachedWebApiToken;
// 1) 同步 DOM 解析(页面应用配置里直接拿)
const syncToken = getAccessTokenSync();
if (syncToken) { _cachedWebApiToken = syncToken; return syncToken; }
// 2) store 首页兜底(与 init 阶段并发共享同一 promise)
const storeToken = await _prefetchWebApiTokenAsync();
if (storeToken) return storeToken;
return null;
}
// v1.1.3: 防御性解析 GetWishlistCategories 响应(返回结构未文档化,兼容多种形态)
// Steam WebAPI 典型响应:{ response: { categories: [{categoryid, category_name, ...}] } }
// 也存在双层包裹:{ response: { response: { categories: [...] } } }
// 以及扁平:{ categories: [...] } / [...] / { id: name }
function parseWishlistCategoriesResponse(data) {
const out = {};
if (!data || typeof data !== 'object') return out;
// 1) 兼容双层 response 包裹(部分私有接口会双层)
let resp = data.response && typeof data.response === 'object' ? data.response : data;
if (resp.response && typeof resp.response === 'object') resp = resp.response;
// 2) 找 categories 数组
const list = resp.categories || resp.wishlist_categories || (Array.isArray(resp) ? resp : null);
if (Array.isArray(list)) {
list.forEach(c => {
if (!c || typeof c !== 'object') return;
const id = c.categoryid ?? c.category_id ?? c.id;
const name = c.category_name ?? c.name ?? c.label ?? c.display_name;
if (id != null && name) out[String(id)] = String(name);
});
}
// 3) 兼容 { id: name } 或 { id: {category_name} } 映射
if (resp && typeof resp === 'object' && !Array.isArray(resp)) {
for (const [k, v] of Object.entries(resp)) {
if (k === 'categories' || k === 'wishlist_categories') continue;
if (k === 'success' || k === 'success_partial' || k === 'servernow') continue;
if (typeof v === 'string' && v) out[k] = v;
else if (v && typeof v === 'object') {
const name = v.category_name ?? v.name ?? v.label;
if (name) out[k] = String(name);
}
}
}
return out;
}
// v1.1.2: 专用 API 请求函数——text 模式获取 + 手动 JSON.parse + HTML 检测
// gmFetch('json') 在 API 返回 HTML(登录页)时 resp.response 为 null,静默失败
function fetchSteamApiJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout: 15000,
onload(resp) {
try {
const text = resp.responseText || '';
if (text.startsWith('<') || text.startsWith(' SSR > API;后台静默校验)
// v1.1.7+: 并发预取优化
// - 步骤 a) 同步检查 state / 同步读 GM 缓存(即时返回)
// - 步骤 b) 若缓存命中,立即应用 + 并发触发 token 预取 + 后台校验
// - 步骤 c) 若缓存缺失,并发启动 token 预取与 API 请求
// - 步骤 d) API 命中后回写 state + GM 缓存(LRU 限 5 steamId)
// 冷启动顺序:
// 1) state 已有 → 直接返回
// 2) GM 缓存(按 steamId 命中且未硬过期)→ 应用 + 后台校验
// 3) API(首次或硬过期)→ 应用 + 写缓存
// 热路径(后台静默校验):
// - 软过期:发起 API 请求,hash 一致则只更新 ts;不一致则更新并 invalidate
async function fetchWishlistCategoryNames() {
const sid = (typeof steamId !== 'undefined' ? steamId : '') || '';
// 1) state 已有名称(SSR 路径已经应用)→ 立即返回 + 后台校验
if (Object.keys(state.wishlistCategoryNames).length > 0) {
_writeCurrentToCache(sid);
_scheduleBackgroundRefresh(sid);
return;
}
// 2) GM 缓存命中(未硬过期)→ 立即应用 + 并发后台校验
const cached = sid ? loadCachedCategoryNames(sid) : null;
if (cached && cached.names && !_isCacheHardExpired(cached)) {
state.wishlistCategoryNames = { ...state.wishlistCategoryNames, ...cached.names };
invalidateStats();
console.log(`[SWE] 类别名称从缓存恢复: ${Object.keys(cached.names).length} 个 (${cached.hash})`);
// 后台静默校验(不阻塞当前返回)
_scheduleBackgroundRefresh(sid);
return;
}
// 3) 缓存未命中或硬过期 → 走 API(带并发预取 token)
// 并发模式:getAccessToken 已内部复用 init 阶段的预取 promise,调用即用
await _fetchAndApplyFromApi(sid, { allowEmpty: true });
}
// 把当前 state 中的类别名写入缓存(SSR 路径用,避免丢失)
function _writeCurrentToCache(sid) {
if (!sid) return;
const cur = state.wishlistCategoryNames;
if (!cur || Object.keys(cur).length === 0) return;
const container = _loadCatNamesCache();
// 仅在 hash 变化时覆盖
const newHash = _hashCategoryNames(cur);
const old = container.bySteamId[sid];
if (old && old.hash === newHash) return;
container.bySteamId[sid] = {
names: { ...cur },
ts: Date.now(),
hash: newHash
};
_saveCatNamesCache(container);
}
// 后台静默校验(不阻塞 UI)
let _bgRefreshTimer = null;
function _scheduleBackgroundRefresh(sid) {
if (_bgRefreshTimer) return;
const cached = sid ? loadCachedCategoryNames(sid) : null;
if (!_isCacheStale(cached)) return; // 软过期以内,无需校验
_bgRefreshTimer = setTimeout(() => {
_bgRefreshTimer = null;
_fetchAndApplyFromApi(sid, { silent: true, allowEmpty: false }).catch(() => {});
}, 3000); // 延迟 3s 启动,避免影响首屏
}
// 实际 API 请求 + 应用
async function _fetchAndApplyFromApi(sid, opts) {
const options = opts || {};
try {
const token = await getAccessToken();
if (!token) {
if (!options.silent) console.warn('[SWE] 无法获取 access_token,跳过类别名称获取');
return false;
}
const url = `https://api.steampowered.com/IWishlistService/GetWishlistCategories/v1/?access_token=${token}`;
const data = await fetchSteamApiJson(url);
const map = parseWishlistCategoriesResponse(data);
if (Object.keys(map).length === 0) {
if (!options.silent && options.allowEmpty) {
console.warn('[SWE] GetWishlistCategories 返回空数据:', data);
}
return false;
}
const newHash = _hashCategoryNames(map);
const oldEntry = sid ? loadCachedCategoryNames(sid) : null;
const oldHash = oldEntry ? oldEntry.hash : '';
if (newHash === oldHash) {
// 内容未变 → 仅更新 ts,避免无效 invalidate
if (sid) {
const container = _loadCatNamesCache();
if (container.bySteamId[sid]) {
container.bySteamId[sid].ts = Date.now();
_saveCatNamesCache(container);
}
}
if (!options.silent) console.log(`[SWE] 类别名称无变化,跳过更新 (${newHash})`);
return false;
}
// 有变化:应用到 state + 写缓存 + 触发刷新
state.wishlistCategoryNames = { ...state.wishlistCategoryNames, ...map };
if (sid) {
const container = _loadCatNamesCache();
container.bySteamId[sid] = {
names: { ...map },
ts: Date.now(),
hash: newHash
};
_saveCatNamesCache(container);
}
invalidateStats();
if (!options.silent) {
console.log(`[SWE] fetched ${Object.keys(map).length} wishlist category names via API`);
renderActiveTab();
} else {
console.log(`[SWE] [bg] wishlist category names updated (${Object.keys(map).length})`);
renderActiveTab();
}
return true;
} catch (e) {
if (!options.silent) console.warn('[SWE] GetWishlistCategories API 失败:', e.message || e);
return false;
}
}
// ============================================================
// 筛选 & 排序
// ============================================================
function getFilteredGames() {
let data = [...state.items];
if (state.search) data = data.filter(g => g.name.toLowerCase().includes(state.search));
if (state.gameFilter === 'discount') data = data.filter(g => g.discountPct > 0 || g.onSale);
else if (state.gameFilter === 'free') data = data.filter(g => g.isFree);
else if (state.gameFilter === 'ea') data = data.filter(g => g.isEarlyAccess);
else if (state.gameFilter === 'soon') data = data.filter(g => g.isComingSoon);
// v1.1.0:仅显示已拥有(ownership 已检查且 owned === true)
if (state.onlyOwned) data = data.filter(g => g._ownedChecked === true && g.owned === true);
// v1.1.1:类别视图筛选('__all__' 或 null 不筛选)
if (state.categoryView && state.categoryFilter && state.categoryFilter !== '__all__') {
if (state.categoryFilter === '__uncat__') {
data = data.filter(g => !Array.isArray(g.categoryIds) || g.categoryIds.length === 0);
} else {
data = data.filter(g => Array.isArray(g.categoryIds) && g.categoryIds.includes(state.categoryFilter));
}
}
const [sk, sd] = state.sortBy.split('-');
data.sort((a, b) => {
let c = 0;
switch (sk) {
case 'priority': c = a.priority - b.priority; break;
case 'name': c = a.name.localeCompare(b.name); break;
case 'price': c = a.finalPrice - b.finalPrice; break;
case 'discount': c = a.discountPct - b.discountPct; break;
case 'added': c = a.addedTimestamp - b.addedTimestamp; break;
case 'review': c = (a.reviewPercent || 0) - (b.reviewPercent || 0); break;
default: c = a.priority - b.priority;
}
return sd === 'asc' ? c : -c;
});
return data;
}
function applyFilters() {
state.filtered = getFilteredGames();
state.page = 1;
renderActiveTab();
// v1.1.0:仅显示已拥有开启但拥有状态未检查时,后台检查后重新筛选
if (state.onlyOwned && !state.ownershipChecked && state.items.length > 0 && !state.ownershipChecking) {
state.ownershipChecking = true;
const total = state.items.length;
updateProgress(0, t('ownershipChecking', { done: 0, total }));
ensureOwnershipForItems(state.items, (done, tot) => {
updateProgress(Math.round(done / tot * 100), t('ownershipChecking', { done, total: tot }));
}).then(() => {
state.ownershipChecking = false;
setTimeout(hideProgress, 600);
applyFilters();
}).catch(() => {
state.ownershipChecking = false;
setTimeout(hideProgress, 600);
toast(t('ownershipCheckFailed'), 'error');
});
}
}
function updateLoadBtn() {
const btn = document.getElementById('swe-load-btn');
if (!btn) return;
if (state.loading) {
btn.disabled = true;
btn.innerHTML = `${ICONS.refresh}`;
btn.title = t('loading');
btn.setAttribute('aria-label', t('loading'));
} else {
btn.disabled = false;
btn.innerHTML = `${ICONS.refresh}`;
// 同一按钮:未加载 = 加载全部;已加载 = 更新数据(增量)
const isRefresh = state.allLoaded || state.items.length > 0;
const tip = isRefresh ? t('refreshData') : t('loadAll');
btn.title = tip;
btn.setAttribute('aria-label', tip);
}
}
// ============================================================
// 统计计算
// ============================================================
let cachedStats = null;
function computeStats() {
if (cachedStats) return cachedStats;
const items = state.items;
const n = items.length;
if (n === 0) return { n: 0 };
const priced = items.filter(i => !i.isFree && i.finalPrice > 0);
// v1.0.8: onSale(bpo.active_discounts)也计入打折,避免 discount_pct=0 但有活动折扣的漏统
const discounted = items.filter(i => i.discountPct > 0 || i.onSale);
const free = items.filter(i => i.isFree);
const ea = items.filter(i => i.isEarlyAccess);
const soon = items.filter(i => i.isComingSoon);
const totalValue = priced.reduce((s, i) => s + i.finalPrice, 0);
const totalOrig = priced.reduce((s, i) => s + i.originalPrice, 0);
const prices = priced.map(i => i.finalPrice).sort((a, b) => a - b);
const discounts = discounted.map(i => i.discountPct).sort((a, b) => a - b);
const avgPrice = prices.length ? totalValue / prices.length : 0;
const medianPrice = prices.length ? prices[Math.floor(prices.length / 2)] : 0;
const avgDiscount = discounts.length ? discounts.reduce((s, d) => s + d, 0) / discounts.length : 0;
const maxDiscount = discounts.length ? discounts[discounts.length - 1] : 0;
const timestamps = items.filter(i => i.addedTimestamp).map(i => i.addedTimestamp);
let yearSpan = 0, earliest = '';
if (timestamps.length) {
const min = Math.min(...timestamps);
const max = Math.max(...timestamps);
earliest = new Date(min * 1000).toLocaleDateString();
yearSpan = Math.round((max - min) / (365.25 * 24 * 3600));
}
const tagMap = {};
items.forEach(item => {
if (item.tags) item.tags.forEach(tag => {
const name = tag.name || tag.tagid;
if (name) tagMap[name] = (tagMap[name] || 0) + 1;
});
});
const topTags = Object.entries(tagMap).sort((a, b) => b[1] - a[1]).slice(0, 15);
const platforms = { windows: 0, mac: 0, linux: 0 };
items.forEach(i => { if (i.platforms) { if (i.platforms.windows) platforms.windows++; if (i.platforms.mac) platforms.mac++; if (i.platforms.linux) platforms.linux++; } });
const types = {};
items.forEach(i => { const tp = i.type || 'game'; types[tp] = (types[tp] || 0) + 1; });
const reviews = items.filter(i => i.reviewScore || i.reviewPercent);
const reviewScores = reviews.map(i => i.reviewPercent || (i.reviewScore ? i.reviewScore : 0)).filter(v => v > 0);
const avgReview = reviewScores.length ? reviewScores.reduce((s, v) => s + v, 0) / reviewScores.length : 0;
cachedStats = {
n, priced: priced.length, discounted: discounted.length, free: free.length, ea: ea.length, soon: soon.length,
// v1.0.8: 用并集补集,避免 EA∩soon 重复扣减导致已发售为负/偏小
released: n - items.filter(i => i.isEarlyAccess || i.isComingSoon).length,
totalValue, totalOrig, saved: totalOrig - totalValue, avgPrice, medianPrice, avgDiscount, maxDiscount,
yearSpan, earliest, topTags, platforms, types, avgReview, reviewCount: reviewScores.length,
discountedRate: n ? discounted.length / n : 0,
tagMap, timestamps,
};
return cachedStats;
}
function computePriceDistribution() {
const ranges = [
{ key: 'free', label: t('priceFree'), count: 0, color: '#6366f1' },
{ key: 'lt50', label: t('priceLt50'), min: 0.01, max: 50, count: 0, color: '#06b6d4' },
{ key: '50_100', label: t('price50_100'), min: 50.01, max: 100, count: 0, color: '#3b82f6' },
{ key: '100_200', label: t('price100_200'), min: 100.01, max: 200, count: 0, color: '#8b5cf6' },
{ key: '200_500', label: t('price200_500'), min: 200.01, max: 500, count: 0, color: '#a855f7' },
{ key: 'gte500', label: t('priceGte500'), min: 500.01, max: Infinity, count: 0, color: '#ec4899' },
// v1.0.5: 无价格分项——未发布/未定价游戏(finalPrice 为空且非免费),此前会被静默漏统
{ key: 'noPrice', label: t('priceNoPrice'), count: 0, color: '#64748b' },
];
for (const item of state.items) {
if (item.isFree) { ranges[0].count++; continue; }
const p = item.finalPrice;
if (!(p > 0)) { ranges[ranges.length - 1].count++; continue; }
for (let i = 1; i < ranges.length - 1; i++) {
if (p >= ranges[i].min && p <= ranges[i].max) { ranges[i].count++; break; }
}
}
return ranges;
}
// 概览专用:价值构成(免费 / 折扣中 / 全价)
function computeValueBreakdown() {
let free = 0, onSale = 0, fullPrice = 0;
for (const i of state.items) {
if (i.isFree) free++;
else if (i.discountPct > 0 || i.onSale) onSale++;
else fullPrice++;
}
return [
{ key: 'free', label: t('kpiFree'), count: free, color: '#6366f1' },
{ key: 'onsale', label: t('kpiSale'), count: onSale, color: '#f59e0b' },
{ key: 'full', label: t('fullPrice'), count: fullPrice, color: '#10b981' },
];
}
// 概览专用:状态分布(已发售 / EA / 即将推出)
function computeStatusDist() {
const s = computeStats();
return [
{ key: 'released', label: t('kpiReleased'), count: s.released, color: '#10b981' },
{ key: 'ea', label: t('kpiEA'), count: s.ea, color: '#fb923c' },
{ key: 'soon', label: t('kpiSoon'), count: s.soon, color: '#60a5fa' },
];
}
// v1.0.5 分析页专用:待上市游戏统计(发行时间 > 今天)
// 维度:30天内 / 90天内 / 更远 / 未定日期;返回 null 表示无待上市游戏
// v1.1.3: 增加 topList 字段——按发行日期升序取前 5,TBA 排到末尾(用户能一眼看出最近要上市的游戏)
function computeUpcoming() {
const DAY = 86400000;
const today = new Date();
today.setHours(0, 0, 0, 0);
const nowMs = today.getTime();
const items = state.items.filter(i => i.isComingSoon);
if (items.length === 0) return null;
const buckets = { d30: 0, d90: 0, later: 0, tba: 0 };
let nearest = null;
// 收集 Top 5 候选:{ms, name, appId, releaseDate, isTBA, daysLeft}
const candidates = [];
for (const item of items) {
const ms = item.releaseDate ? new Date(item.releaseDate + 'T00:00:00').getTime() : NaN;
if (!isFinite(ms)) { buckets.tba++; candidates.push({ ms: Infinity, name: item.name, appId: item.appId, releaseDate: item.releaseDate || '', isTBA: true, daysLeft: null }); continue; }
const diff = ms - nowMs;
if (diff <= 30 * DAY) buckets.d30++;
else if (diff <= 90 * DAY) buckets.d90++;
else buckets.later++;
if (!nearest || ms < nearest.ms) nearest = { name: item.name, appId: item.appId, date: item.releaseDate, ms };
candidates.push({ ms, name: item.name, appId: item.appId, releaseDate: item.releaseDate, isTBA: false, daysLeft: Math.max(0, Math.round(diff / DAY)) });
}
// 排序:TBA (Infinity) 排到末尾,有日期的按 ms 升序
candidates.sort((a, b) => a.ms - b.ms);
const topList = candidates.slice(0, 5);
const rows = [
{ label: t('up30'), count: buckets.d30, color: '#10b981' },
{ label: t('up90'), count: buckets.d90, color: '#06b6d4' },
{ label: t('upLater'), count: buckets.later, color: '#8b5cf6' },
{ label: t('upTBA'), count: buckets.tba, color: '#64748b' },
];
return { total: items.length, rows, nearest, topList };
}
// v1.1.0: 用户自定义愿望单类别分布("我的类别"统计)
// 返回 { rows: [{label, count, color, pct}], uncatCount, total } 或 null(无类别数据时)
function computeCategoryDist() {
const catNames = state.wishlistCategoryNames || {};
const catMap = {};
let uncatStats = { count: 0, totalValue: 0, paidCount: 0, discountCount: 0, comingSoonCount: 0, inLibraryCount: 0, freeCount: 0 };
const colors = ['#8b5cf6', '#3b82f6', '#06b6d4', '#a855f7', '#6366f1', '#ec4899', '#10b981', '#f59e0b', '#fb923c', '#22d3ee', '#34d399', '#f472b6', '#a78bfa', '#60a5fa', '#fbbf24'];
for (const item of state.items) {
const ids = Array.isArray(item.categoryIds) ? item.categoryIds : [];
const isPaid = !item.isFree && item.finalPrice > 0;
const isDisc = item.discountPct > 0 || item.onSale;
const isSoon = !!item.isComingSoon;
const isFree = !!item.isFree;
const isOwned = item._ownedChecked && item.owned;
if (ids.length === 0) {
uncatStats.count++;
if (isPaid) { uncatStats.totalValue += item.finalPrice; uncatStats.paidCount++; }
if (isDisc) uncatStats.discountCount++;
if (isSoon) uncatStats.comingSoonCount++;
if (isOwned) uncatStats.inLibraryCount++;
if (isFree) uncatStats.freeCount++;
continue;
}
for (const id of ids) {
const key = String(id);
if (!catMap[key]) catMap[key] = {
count: 0, totalValue: 0, paidCount: 0,
discountCount: 0, comingSoonCount: 0, inLibraryCount: 0, freeCount: 0,
name: catNames[key] || ('#' + key),
};
const cs = catMap[key];
cs.count++;
if (isPaid) { cs.totalValue += item.finalPrice; cs.paidCount++; }
if (isDisc) cs.discountCount++;
if (isSoon) cs.comingSoonCount++;
if (isOwned) cs.inLibraryCount++;
if (isFree) cs.freeCount++;
}
}
const entries = Object.entries(catMap).sort((a, b) => b[1].count - a[1].count);
if (entries.length === 0 && uncatStats.count === 0) return null;
const total = state.items.length;
const rows = entries.map(([id, info], i) => ({
id, label: info.name, count: info.count,
color: colors[i % colors.length],
pct: total > 0 ? info.count / total : 0,
totalValue: info.totalValue,
avgPrice: info.paidCount > 0 ? info.totalValue / info.paidCount : 0,
discountCount: info.discountCount,
comingSoonCount: info.comingSoonCount,
inLibraryCount: info.inLibraryCount,
freeCount: info.freeCount,
}));
const uncat = {
id: '__uncat__', label: t('catUncategorized'), count: uncatStats.count,
color: '#64748b', pct: total > 0 ? uncatStats.count / total : 0,
totalValue: uncatStats.totalValue,
avgPrice: uncatStats.paidCount > 0 ? uncatStats.totalValue / uncatStats.paidCount : 0,
discountCount: uncatStats.discountCount,
comingSoonCount: uncatStats.comingSoonCount,
inLibraryCount: uncatStats.inLibraryCount,
freeCount: uncatStats.freeCount,
};
return { rows, uncat, uncatCount: uncatStats.count, total, hasCategories: entries.length > 0 };
}
function computeYearTrend() {
const yearMap = {};
state.items.forEach(item => {
if (!item.addedTimestamp) return;
const year = new Date(item.addedTimestamp * 1000).getFullYear();
yearMap[year] = (yearMap[year] || 0) + 1;
});
const years = Object.entries(yearMap).map(([year, count]) => ({ year: +year, count })).sort((a, b) => a.year - b.year);
const max = Math.max(1, ...years.map(y => y.count));
years.forEach(y => y.pct = y.count / max);
return years;
}
// 季度趋势:展示最近3年(12个季度)
function computeQuarterTrend() {
const now = new Date();
const quarters = [];
const currentQ = Math.floor(now.getMonth() / 3);
const currentY = now.getFullYear();
// 最近12个季度
for (let i = 11; i >= 0; i--) {
let q = currentQ - i;
let y = currentY;
while (q < 0) { q += 4; y--; }
quarters.push({ year: y, quarter: q, count: 0, label: `${y} ${t('quarterLabel')}${q + 1}` });
}
state.items.forEach(item => {
if (!item.addedTimestamp) return;
const d = new Date(item.addedTimestamp * 1000);
const q = Math.floor(d.getMonth() / 3);
const y = d.getFullYear();
const found = quarters.find(s => s.year === y && s.quarter === q);
if (found) found.count++;
});
return quarters;
}
// 月度趋势:展示最近12个月
function computeMonthTrend() {
const now = new Date();
const months = [];
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const mLabels = t('monthLabels');
months.push({ year: d.getFullYear(), month: d.getMonth(), count: 0, label: mLabels[d.getMonth()] });
}
state.items.forEach(item => {
if (!item.addedTimestamp) return;
const d = new Date(item.addedTimestamp * 1000);
const found = months.find(s => s.year === d.getFullYear() && s.month === d.getMonth());
if (found) found.count++;
});
return months;
}
// v1.0.4: 热力图按年绘制——单次遍历生成全局 dayMap + 按年 yearDayMaps
function computeHeatmap() {
const dayMap = {};
const yearDayMaps = {};
let firstTs = 0, lastTs = 0;
state.items.forEach(item => {
if (!item.addedTimestamp) return;
const ts = item.addedTimestamp;
const d = new Date(ts * 1000);
const y = d.getFullYear();
const key = y + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
dayMap[key] = (dayMap[key] || 0) + 1;
const ym = yearDayMaps[y] || (yearDayMaps[y] = {});
ym[key] = (ym[key] || 0) + 1;
if (!firstTs || ts < firstTs) firstTs = ts;
if (!lastTs || ts > lastTs) lastTs = ts;
});
const years = Object.keys(yearDayMaps).map(Number).sort((a, b) => a - b);
return { dayMap, yearDayMaps, years, firstTs, lastTs };
}
function computePriorityDist() {
const buckets = { '1': 0, '2': 0, '3': 0 };
state.items.forEach(i => {
const p = i.priority || 0;
if (p === 0 || p === 1) buckets['1']++;
else if (p === 2) buckets['2']++;
else buckets['3']++;
});
return [
{ label: t('priorityAsc'), count: buckets['1'], color: '#8b5cf6' },
{ label: '★★', count: buckets['2'], color: '#3b82f6' },
{ label: '★', count: buckets['3'], color: '#06b6d4' },
];
}
function computeTopDiscount() {
return [...state.items].filter(i => i.discountPct > 0).sort((a, b) => b.discountPct - a.discountPct).slice(0, 10);
}
function computeReviewDist() {
const buckets = [
{ label: '95+', min: 95, max: 100, count: 0, color: '#4ade80' },
{ label: '80-95', min: 80, max: 94, count: 0, color: '#3b82f6' },
{ label: '70-80', min: 70, max: 79, count: 0, color: '#fbbf24' },
{ label: '50-70', min: 50, max: 69, count: 0, color: '#fb923c' },
{ label: '<50', min: 0, max: 49, count: 0, color: '#f87171' },
];
state.items.forEach(i => {
const r = i.reviewPercent || (i.reviewScore > 10 ? i.reviewScore : 0);
if (r > 0) {
for (const b of buckets) {
if (r >= b.min && r <= b.max) { b.count++; break; }
}
}
});
return buckets;
}
function computeTimeline() {
const items = state.items.filter(i => i.addedTimestamp).map(i => ({ ts: i.addedTimestamp, name: i.name, appId: i.appId, isFree: i.isFree, discountPct: i.discountPct }));
items.sort((a, b) => b.ts - a.ts);
const top = items.slice(0, 50);
const groups = {};
top.forEach(item => {
const d = new Date(item.ts * 1000);
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
if (!groups[dateKey]) groups[dateKey] = { date: d, items: [] };
groups[dateKey].items.push(item);
});
return Object.entries(groups).map(([key, val]) => ({ dateKey: key, date: val.date, items: val.items }))
.sort((a, b) => b.date - a.date);
}
function invalidateStats() { cachedStats = null; }
// ============================================================
// 格式化工具
// ============================================================
function fmtPrice(v) {
const n = Number(v) || 0;
const cur = state.currency;
if (cur && cur.symbol) {
if (cur.id === 'CNY' || cur.id === 'JPY') return cur.symbol + Math.round(n);
return cur.symbol + n.toFixed(2);
}
return '¥' + Math.round(n);
}
// 紧凑价格:大数字自动 K / 万 单位,避免 KPI 卡片显示溢出
function fmtPriceCompact(v) {
const n = Number(v) || 0;
const cur = state.currency;
const sym = (cur && cur.symbol) || '¥';
const isCJK = locale === 'zh-CN' || locale === 'zh-TW' || locale === 'zh-HK' || locale === 'zh-MO';
if (n >= 100000) {
// 10 万 / 100K+ 用 1 位小数(¥45.6万 / $456.7K)
const divisor = isCJK ? 10000 : 1000;
const unit = isCJK ? '万' : 'K';
return sym + (n / divisor).toFixed(1) + unit;
}
if (n >= 10000) {
// 1 万~10 万 / 10K~100K 用紧凑(¥4.56万 / $45.6K)
const divisor = isCJK ? 10000 : 1000;
const unit = isCJK ? '万' : 'K';
const val = n / divisor;
return sym + (val >= 10 ? val.toFixed(1) : val.toFixed(2).replace(/\.?0+$/, '')) + unit;
}
return fmtPrice(n);
}
function fmtDate(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function fmtDateLabel(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleDateString(locale === 'en' ? 'en-US' : 'zh-CN', { month: 'short', day: 'numeric' });
}
function escapeHTML(s) {
const div = document.createElement('div');
div.textContent = String(s ?? '');
return div.innerHTML;
}
function pct(n) { return (n * 100).toFixed(0) + '%'; }
// ============================================================
// SVG 图表渲染
// ============================================================
// 趋势曲线图:SVG area chart with smooth curve, gradient fill, data points, grid, axis labels
function renderTrendChart(data) {
if (!data || data.length === 0) return `${t('noData')}
`;
const W = 640, H = 216;
const padL = 44, padR = 16, padT = 30, padB = 26;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
const rawMax = Math.max(1, ...data.map(d => d.count));
// Y 轴留 20% 余量,确保峰值标注不被裁剪
const maxVal = Math.ceil(rawMax * 1.2);
// Y 轴刻度:生成 4 段(基于含余量的 maxVal)
const yTicks = [];
const tickCount = 4;
for (let i = 0; i <= tickCount; i++) {
const v = Math.round((maxVal / tickCount) * i);
yTicks.push(v);
}
const xStep = data.length > 1 ? chartW / (data.length - 1) : 0;
// 数据点坐标
const points = data.map((d, i) => ({
x: padL + i * xStep,
y: padT + chartH - (d.count / maxVal) * chartH,
count: d.count,
label: d.label
}));
// 平滑曲线(Catmull-Rom → Bezier)
let pathD = '';
if (points.length === 1) {
pathD = `M ${points[0].x} ${points[0].y}`;
} else {
pathD = `M ${points[0].x} ${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1] || points[i];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2] || p2;
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
pathD += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
}
}
// 填充区域路径
const areaD = pathD + ` L ${points[points.length - 1].x.toFixed(1)} ${padT + chartH} L ${points[0].x.toFixed(1)} ${padT + chartH} Z`;
// 水平网格线
const gridLines = yTicks.map(v => {
const y = padT + chartH - (v / maxVal) * chartH;
return ``;
}).join('');
// Y 轴标签
const yLabels = yTicks.map(v => {
const y = padT + chartH - (v / maxVal) * chartH;
return `${v}`;
}).join('');
// X 轴标签:数据点多时隔点显示
const labelInterval = data.length > 10 ? Math.ceil(data.length / 8) : 1;
const xLabels = points.map((p, i) => {
if (i % labelInterval !== 0 && i !== points.length - 1) return '';
return `${escapeHTML(p.label)}`;
}).join('');
// 数据点圆圈 + hover tooltip
const dots = points.map(p => {
return ``;
}).join('');
// 峰值标注:用 rawMax 查找(maxVal 是含余量的上界,恒大于任意数据点)
const maxIdx = data.findIndex(d => d.count === rawMax);
const peakPoint = points[maxIdx];
const peakLabel = peakPoint && rawMax > 0
? `${rawMax}`
: '';
return `
`;
}
// v1.0.4: 热力图按年绘制——年份选择器 + SVG 网格 + 图例/统计行,默认展示当年
function renderHeatmap(hm) {
if (!hm.years.length) return `${t('noData')}
`;
const cellSize = 14, cellGap = 3, cellStep = cellSize + cellGap;
const labelW = 24, yearH = 14, monthH = 16;
const monthNames = t('monthLabels');
const dayNames = ['', t('mon'), '', t('wed'), '', t('fri'), ''];
// 紫色 5 级色阶(暗→亮),与主题一致
const colors = ['rgba(255,255,255,0.05)', 'rgba(139,92,246,0.25)', 'rgba(139,92,246,0.45)', 'rgba(139,92,246,0.68)', '#a78bfa'];
function heatColor(count) {
if (count <= 0) return colors[0];
if (count === 1) return colors[1];
if (count === 2) return colors[2];
if (count <= 4) return colors[3];
return colors[4];
}
const fmtD = (d) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const legend = `${t('heatmapLess')}${colors.map(c => ``).join('')}${t('heatmapMore')}
`;
// 构建单个视图(某一年 / 全部):SVG + 底部统计/图例行
// showYearRow: 是否渲染顶部年份标签与分隔线(仅"全部"跨年视图需要)
function buildView(viewDayMap, rangeStartDate, rangeEndDate, avgSpanDays, showYearRow) {
const globalStart = new Date(rangeStartDate);
globalStart.setHours(0, 0, 0, 0);
const endDate = new Date(rangeEndDate);
endDate.setHours(0, 0, 0, 0);
const gridStart = new Date(globalStart);
gridStart.setDate(gridStart.getDate() - globalStart.getDay());
const totalDays = Math.floor((endDate - gridStart) / 86400000) + 1;
const totalWeeks = Math.ceil(totalDays / 7);
const topH = (showYearRow ? yearH : 0) + monthH;
const svgW = labelW + totalWeeks * cellStep + 8;
const svgH = topH + 7 * cellStep + 8;
let maxDaily = 0, total = 0;
for (const k in viewDayMap) { if (viewDayMap[k] > maxDaily) maxDaily = viewDayMap[k]; total += viewDayMap[k]; }
const avgDaily = (total / Math.max(1, avgSpanDays)).toFixed(2);
const cellParts = [], monthLabelPositions = [], yearBoundaries = [];
let lastMonth = -1, lastYear = -1;
const startMs = globalStart.getTime(), endMs = endDate.getTime();
for (let w = 0; w < totalWeeks; w++) {
for (let d = 0; d < 7; d++) {
const cellTs = gridStart.getTime() + (w * 7 + d) * 86400000;
if (cellTs < startMs || cellTs > endMs) continue;
const cellDate = new Date(cellTs);
const key = cellDate.getFullYear() + '-' + String(cellDate.getMonth() + 1).padStart(2, '0') + '-' + String(cellDate.getDate()).padStart(2, '0');
const cnt = viewDayMap[key] || 0;
const x = labelW + w * cellStep, y = topH + d * cellStep;
if (cnt > 0) {
cellParts.push(`${key} · ${cnt}`);
} else {
cellParts.push(``);
}
if (d === 0) {
if (cellDate.getMonth() !== lastMonth) {
lastMonth = cellDate.getMonth();
monthLabelPositions.push({ w, month: cellDate.getMonth() });
}
if (showYearRow && cellDate.getFullYear() !== lastYear) {
lastYear = cellDate.getFullYear();
yearBoundaries.push({ w, year: cellDate.getFullYear() });
}
}
}
}
// 年份分隔线 + 年份标签(仅跨年视图;首个边界不画线避免与轴重叠)
const yearParts = [];
for (const yb of yearBoundaries) {
if (yb.w > 0) {
const lineX = labelW + yb.w * cellStep - cellGap / 2;
yearParts.push(``);
}
yearParts.push(`${yb.year}`);
}
const monthParts = monthLabelPositions.map(ml =>
`${monthNames[ml.month]}`
).join('');
const dayParts = [];
for (let di = 0; di < 7; di++) {
if (dayNames[di]) dayParts.push(`${dayNames[di]}`);
}
const svgStr = ``;
const summary = `
${t('heatmapTotal')}: ${total}
${t('heatmapPeak')}: ${maxDaily}
${t('heatmapAvg')}: ${avgDaily}
${fmtD(globalStart)} ~ ${fmtD(endDate)}
`;
return `${svgStr}
`;
}
// 预计算各视图:每年一个单年视图 + 跨年"全部"视图
const nowYear = new Date().getFullYear();
const today = new Date();
today.setHours(0, 0, 0, 0);
const views = {};
for (const y of hm.years) {
const yStart = new Date(y, 0, 1);
const yEnd = y === nowYear ? today : new Date(y, 11, 31);
const avgSpan = Math.floor((yEnd - yStart) / 86400000) + 1;
views[String(y)] = buildView(hm.yearDayMaps[y], yStart, yEnd, avgSpan, false);
}
const allAvgSpan = Math.max(1, Math.ceil((hm.lastTs - hm.firstTs) / 86400) + 1);
views.all = buildView(hm.dayMap, new Date(hm.firstTs * 1000), today, allAvgSpan, true);
heatmapViews = views;
// 年份选择器(最新在前,末尾"全部"),默认展示当年(当年无数据则最新年份);仅一年时不显示
let filterHtml = '', defaultView = 'all';
if (hm.years.length > 1) {
defaultView = hm.yearDayMaps[nowYear] ? String(nowYear) : String(hm.years[hm.years.length - 1]);
const btns = [...hm.years].reverse().map(y =>
``
).join('');
filterHtml = `${btns}
`;
} else {
defaultView = String(hm.years[0]);
}
return `
${filterHtml}
${views[defaultView]}
`;
}
// ============================================================
// v1.2.2 年度大作数据(参考 steam-friend-manager 精品游戏模块)
// ============================================================
const AAA_JSON_URL = 'https://raw.githubusercontent.com/SmallFork/json/main/2026.json';
let _aaaGamesCache = null; // 缓存全部年度大作数据
let _aaaLoadFailed = false;
let _aaaLoading = false;
// 获取年度大作列表(数组格式: [{name, date, appid}])
function getAaaAllGames() {
if (!_aaaGamesCache) return [];
if (Array.isArray(_aaaGamesCache)) return _aaaGamesCache;
const all = [];
Object.values(_aaaGamesCache).forEach(v => { if (Array.isArray(v)) all.push(...v); });
return all;
}
// 异步加载年度大作数据(仅加载一次,后续读缓存)
function loadAaaGamesData() {
if (_aaaGamesCache || _aaaLoadFailed || _aaaLoading) return;
_aaaLoading = true;
GM_xmlhttpRequest({
method: 'GET', url: AAA_JSON_URL, timeout: 15000,
onload(resp) {
try {
const data = JSON.parse(resp.responseText);
_aaaGamesCache = data;
_aaaLoadFailed = false;
} catch (e) { _aaaLoadFailed = true; }
_aaaLoading = false;
// 加载完成后刷新概览标签页(如果当前正在显示)
if (activeTab === 'overview') renderActiveTab();
},
onerror() { _aaaLoadFailed = true; _aaaLoading = false; },
ontimeout() { _aaaLoadFailed = true; _aaaLoading = false; }
});
}
// 解析中文日期格式 "2026年1月22日" → Date
function parseAaaDate(dateStr) {
if (!dateStr) return null;
const m = String(dateStr).match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日/);
if (m) return new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]));
return null;
}
// 匹配愿望单中的年度大作:即将发售的大作 或 未拥有的已发售大作
function computeAaaWishlist() {
const aaaGames = getAaaAllGames();
if (!aaaGames.length) return null;
const now = new Date();
now.setHours(0, 0, 0, 0);
// 建立愿望单 appid → item 索引
const wlMap = new Map(state.items.map(it => [String(it.appId), it]));
const matched = [];
for (const game of aaaGames) {
const appid = String(game.appid);
const wlItem = wlMap.get(appid);
if (!wlItem) continue; // 不在愿望单中,跳过
const releaseDate = parseAaaDate(game.date);
const isComingSoon = releaseDate ? releaseDate >= now : !!wlItem.isComingSoon;
const isOwned = wlItem._ownedChecked && wlItem.owned;
// 匹配条件:即将发售的大作 或 未拥有的已发售大作
if (isComingSoon || !isOwned) {
matched.push({
appId: game.appid,
name: wlItem.name || game.name,
date: game.date || '',
releaseDate,
isComingSoon,
isOwned,
price: wlItem.finalPrice > 0 ? wlItem.finalPrice : null,
formattedPrice: wlItem.formattedPrice || '',
discountPct: wlItem.discountPct || 0,
});
}
}
// 排序:即将发售优先(按日期升序),然后未拥有的已发售
matched.sort((a, b) => {
if (a.isComingSoon && !b.isComingSoon) return -1;
if (!a.isComingSoon && b.isComingSoon) return 1;
if (a.releaseDate && b.releaseDate) return a.releaseDate - b.releaseDate;
if (a.releaseDate) return -1;
if (b.releaseDate) return 1;
return 0;
});
return matched.length > 0 ? matched.slice(0, 10) : [];
}
// 渲染大作夙愿卡片
function renderAaaWishCard() {
// 触发异步加载(首次)
loadAaaGamesData();
if (!_aaaGamesCache && !_aaaLoadFailed) {
// 数据还在加载中,静默不展示(避免闪烁)
return '';
}
if (_aaaLoadFailed && !_aaaGamesCache) return '';
const matched = computeAaaWishlist();
if (!matched || matched.length === 0) return '';
const itemsHtml = matched.map(g => {
const storeUrl = `https://store.steampowered.com/app/${g.appId}`;
const posterUrl = `https://cdn.cloudflare.steamstatic.com/steam/apps/${g.appId}/capsule_231x87.jpg`;
// 日期显示(静默失败:无日期则不展示)
const dateHtml = g.date ? `${escapeHTML(g.date)}` : '';
// 价格显示(静默失败:无价格则不展示)
const priceHtml = g.formattedPrice
? `${escapeHTML(g.formattedPrice)}`
: (g.price ? `${fmtPrice(g.price)}` : '');
// 状态徽章
let badgeHtml = '';
if (g.isComingSoon) badgeHtml = `${t('aaaWishSoon')}`;
else if (g.isOwned) badgeHtml = `${t('aaaWishOwned')}`;
else badgeHtml = `${t('aaaWishNotOwned')}`;
return `
${escapeHTML(g.name)}
${dateHtml}${priceHtml}
${badgeHtml}
`;
}).join('');
return `
${ICONS.trophy}${t('chartAaaWish')}${t('aaaWishSub')}
${itemsHtml}
`;
}
// ============================================================
// 概览标签页(参考 steam-friend-manager 仪表盘设计)
// 视角:数量 / 价值 / 节省 / 折扣 / 质量
// 注:类型 / 平台 / 评价 / 优先级 留给"分析",年度 / 热力 / 时间线 留给"趋势"
// ============================================================
function renderOverview() {
const s = computeStats();
if (s.n === 0) return ``;
const cur = state.currency;
const curSub = cur ? `${cur.symbol} ${cur.id}` : '';
// ── 顶部 KPI 行:5 个不同视角的紧凑卡片(横向布局) ──
// 总价值已由下方环形图中心展示,故此处不放
const kpiCards = [
{ cls: 'kpi-total', icon: ICONS.heart, val: s.n, lbl: t('kpiTotal'), sub: curSub, color: '#f43f5e', bg: 'rgba(244,63,94,0.15)' },
{ cls: 'kpi-avg', icon: ICONS.dollar, val: fmtPrice(s.avgPrice), lbl: t('kpiAvgPrice'), sub: `${s.priced} ${t('games')}`, color: '#10b981', bg: 'rgba(16,185,129,0.15)' },
{ cls: 'kpi-saved', icon: ICONS.gift, val: fmtPriceCompact(s.saved), lbl: t('kpiSaved'), sub: pct(s.discountedRate) + ' ' + t('kpiSale'), color: '#f59e0b', bg: 'rgba(245,158,11,0.15)' },
{ cls: 'kpi-discount', icon: ICONS.fire, val: s.discounted, lbl: t('kpiSale'), sub: `${t('kpiAvgDiscount')} ${s.avgDiscount.toFixed(0)}%`, color: '#a855f7', bg: 'rgba(168,85,247,0.15)' },
{ cls: 'kpi-review', icon: ICONS.star, val: s.avgReview ? s.avgReview.toFixed(0) + '%' : '—', lbl: t('kpiAvgReview'), sub: `${s.reviewCount || 0} ${t('games')}`, color: '#06b6d4', bg: 'rgba(6,182,212,0.15)' },
];
const kpiHtml = `${kpiCards.map(k => `
${k.icon}
${k.val}
${k.lbl}
${k.sub ? `
${k.sub}
` : ''}
`).join('')}
`;
// ── 数据准备 ──
const valueBreakdown = computeValueBreakdown();
const priceDist = computePriceDistribution();
const statusDist = computeStatusDist();
const topDisc = computeTopDiscount().slice(0, 5);
// ── 价值构成(环形居中 + 图例底部) ──
const valueCard = `
${ICONS.barChart}${t('chartValueBreakdown')}${s.n} ${t('games')}
${renderOverviewDonut(valueBreakdown, fmtPrice(s.totalValue), t('kpiValue'))}
${valueBreakdown.map(d => {
const pctVal = s.n ? (d.count / s.n * 100).toFixed(0) : 0;
return `
${escapeHTML(d.label)}
${d.count} · ${pctVal}%
`;
}).join('')}
`;
// ── TOP 5 折扣排行 ──
const topDiscCard = topDisc.length > 0 ? `
${ICONS.fire}${t('chartTopDiscount')}TOP 5
` : '';
// ── 价格区间分布 ──
const priceCard = `
${ICONS.dollar}${t('chartPriceDist')}
${priceDist.map(d => {
const max = Math.max(1, ...priceDist.map(x => x.count));
const w = (d.count / max) * 100;
return `
${escapeHTML(d.label)}
${d.count}
`;
}).join('')}
`;
// ── 状态分布(已发售 / EA / 即将推出) ──
const statusCard = `
${ICONS.gamepad}${t('chartStatusDist')}
${statusDist.map(d => {
const max = Math.max(1, ...statusDist.map(x => x.count));
const w = (d.count / max) * 100;
return `
${escapeHTML(d.label)}
${d.count}
`;
}).join('')}
`;
// v1.2.2: 大作夙愿栏目(折扣TOP下方)
const aaaWishCard = renderAaaWishCard();
// v1.2.2: KPI 固定在滚动容器外部,滚动从下方内容开始
return `
${valueCard}${statusCard}
${topDiscCard}
${aaaWishCard}
${priceCard}
`;
}
// 概览专用大环形图(中心显示总价值)
function renderOverviewDonut(data, centerVal, centerLbl) {
const total = data.reduce((s, d) => s + d.count, 0);
if (total === 0) return `${t('noData')}
`;
const size = 168, thick = 20;
const r = (size - thick) / 2;
const cx = size / 2, cy = size / 2;
const circ = 2 * Math.PI * r;
let offset = 0;
const arcs = data.map(d => {
const frac = d.count / total;
const dash = frac * circ;
const arc = ``;
offset += dash;
return arc;
}).join('');
return `
${centerVal}
${centerLbl}
`;
}
// ============================================================
// 游戏库标签页
// ============================================================
// CDN 基础 + 完整 fallback 链(参考 v1.58 faLoadCover + steam-activity-modern-4.3)
const SWE_CDN_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/';
const SWE_CDN_AKAMAI = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/';
// 卡片(竖图 2:3)封面链:先用小尺寸快速加载,再尝试大图提升清晰度
const SWE_CARD_CHAIN = ['header.jpg', 'library_600x900.jpg', 'library_600x900_2x.jpg', 'capsule_231x87.jpg'];
// 列表(横图 7:3)封面链:先用 akamai header(加载快),再 cloudflare capsule
const SWE_LIST_CHAIN = ['header.jpg', 'capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg'];
// 横板封面视图专用链(横版胶囊图,184/69 比例)
const SWE_COVER_CHAIN = ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'capsule_231x87.png', 'capsule_184x69.png', 'header.jpg'];
// 最终 SVG placeholder(兜底显示)
const SWE_SVG_PLACEHOLDER = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 100'%3E%3Crect fill='%231a1f3a' width='200' height='100'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%2364748b' font-family='system-ui' font-size='11'%3E🎮 No Image%3C/text%3E%3C/svg%3E";
// 缓存:appid → 已确认 url(空字符串表示"已确认无图")
const _sweCapsuleCache = new Map();
// 缓存:appid → 等待解析的 promise list
const _sweCapsuleWaiters = new Map();
// v1.0.3:已验证可用的封面 URL 缓存(跨视图共享,参考 v1.58 _faCoverGood)
const _sweCoverGood = new Map();
// v1.1.9:封面 URL 持久化缓存(跨会话)
// - key: appid (string)
// - value: { url: string, ts: number, view: 'card'|'list'|'cover' }
// - 容量上限 500,超过按 LRU(ts) 淘汰
// - 解决"特殊游戏多次降级后才能加载"问题:首次会话降级成功后写入,
// 后续会话直接命中持久化 URL 跳过整条 fallback 链
const SWE_COVER_URL_KEY = 'swe_cover_url_v1';
const SWE_COVER_URL_MAX = 500;
let _sweCoverUrlPersisted = null; // null=未加载, {} 或 {appid: {...}}
function loadCoverUrlPersisted() {
if (_sweCoverUrlPersisted !== null) return _sweCoverUrlPersisted;
_sweCoverUrlPersisted = {};
try {
const raw = GM_getValue(SWE_COVER_URL_KEY);
if (raw && typeof raw === 'object') _sweCoverUrlPersisted = raw;
} catch (e) { /* ignore */ }
// 同步预热到内存缓存 _sweCoverGood
const cur = _sweCoverUrlPersisted;
for (const id in cur) {
const ent = cur[id];
if (ent && ent.url) _sweCoverGood.set(id, ent.url);
}
return _sweCoverUrlPersisted;
}
// 写入单条(带 LRU 淘汰 + 异步落盘)
let _sweCoverUrlWriteTimer = null;
let _sweCoverUrlWriteDirty = false;
function setCoverUrlPersisted(appId, url, view) {
if (!appId || !url) return;
const id = String(appId);
const container = loadCoverUrlPersisted();
// LRU: 写过的视为最新
container[id] = { url, ts: Date.now(), view: view || 'card' };
const ids = Object.keys(container);
if (ids.length > SWE_COVER_URL_MAX) {
ids.sort((a, b) => (container[a].ts || 0) - (container[b].ts || 0));
while (ids.length > SWE_COVER_URL_MAX) {
delete container[ids.shift()];
}
}
_sweCoverUrlWriteDirty = true;
// 同步刷写:50ms 节流,避免高频 load 事件触发风暴
if (_sweCoverUrlWriteTimer) return;
_sweCoverUrlWriteTimer = setTimeout(() => {
_sweCoverUrlWriteTimer = null;
if (!_sweCoverUrlWriteDirty) return;
_sweCoverUrlWriteDirty = false;
try { GM_setValue(SWE_COVER_URL_KEY, container); } catch (e) { /* ignore */ }
}, 50);
}
// 立即刷写(清缓存前 / 卸载前使用)
function flushCoverUrlPersisted() {
if (_sweCoverUrlWriteTimer) {
clearTimeout(_sweCoverUrlWriteTimer);
_sweCoverUrlWriteTimer = null;
}
if (_sweCoverUrlWriteDirty && _sweCoverUrlPersisted) {
_sweCoverUrlWriteDirty = false;
try { GM_setValue(SWE_COVER_URL_KEY, _sweCoverUrlPersisted); } catch (e) { /* ignore */ }
}
}
// 清空持久化缓存(清全部缓存入口)
function clearCoverUrlPersisted() {
_sweCoverUrlPersisted = {};
_sweCoverGood.clear();
try { GM_setValue(SWE_COVER_URL_KEY, {}); } catch (e) { /* ignore */ }
}
// 卡片图片 HTML(不带 onerror 链,错误由全局委托器处理)
// 优先使用 akamai CDN(参考好友管理器策略,通常加载更快)
// v1.1.9:src 优先使用持久化缓存的最终 URL(loadCoverUrlPersisted 已同步预热 _sweCoverGood)
function getCardImgHtml(appId, name) {
const id = String(appId);
loadCoverUrlPersisted(); // 确保 _sweCoverGood 已预热
const goodUrl = _sweCoverGood.get(id);
const url = goodUrl || (SWE_CDN_AKAMAI + id + '/' + SWE_CARD_CHAIN[0]);
return `
`;
}
// 列表图片 HTML
function getListImgHtml(appId, name) {
const id = String(appId);
loadCoverUrlPersisted();
const goodUrl = _sweCoverGood.get(id);
const url = goodUrl || (SWE_CDN_AKAMAI + id + '/' + SWE_LIST_CHAIN[0]);
return `
`;
}
// 横板封面图片 HTML(参考 v1.58 fa-wl-cover-cap,使用大横幅胶囊图)
function getCoverImgHtml(appId, name) {
const id = String(appId);
loadCoverUrlPersisted();
const goodUrl = _sweCoverGood.get(id);
const url = goodUrl || (SWE_CDN_AKAMAI + id + '/' + SWE_COVER_CHAIN[0]);
return `
`;
}
// 通过 Steam appdetails API 获取封面 url(fallback 链全失败时调用)
function fetchCapsuleFromAPI(appId) {
return new Promise((resolve) => {
if (_sweCapsuleCache.has(appId)) {
resolve(_sweCapsuleCache.get(appId));
return;
}
if (_sweCapsuleWaiters.has(appId)) {
_sweCapsuleWaiters.get(appId).push(resolve);
return;
}
_sweCapsuleWaiters.set(appId, [resolve]);
const apiUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=us`;
const finish = (url) => {
_sweCapsuleCache.set(appId, url || '');
const waiters = _sweCapsuleWaiters.get(appId) || [];
_sweCapsuleWaiters.delete(appId);
waiters.forEach(w => w(url || ''));
};
const doReq = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest : null;
if (doReq) {
doReq({
method: 'GET', url: apiUrl, timeout: 8000,
onload: (r) => {
try {
const j = JSON.parse(r.responseText);
const d = j[appId];
// 优先用 header_image(更稳),其次 capsule_image
const url = d?.success ? (d.data?.header_image || d.data?.capsule_image || '') : '';
finish(url);
} catch (e) { finish(''); }
},
onerror: () => finish(''),
ontimeout: () => finish(''),
});
} else {
fetch(apiUrl).then(r => r.json()).then(j => {
const d = j[appId];
const url = d?.success ? (d.data?.header_image || d.data?.capsule_image || '') : '';
finish(url);
}).catch(() => finish(''));
}
});
}
// 单张图片 fallback 加载:good URL 缓存 → CDN 链(Cloudflare+Akamai) → API → SVG
// 参考 v1.58 faLoadCover:多 CDN fallback + good URL 缓存 + API 兜底
// v1.1.9:在 onload / API 命中时写入持久化缓存(swe_cover_url_v1),后续会话直接命中最终 URL
function loadImageWithFallback(img) {
const appId = img.dataset.appid;
if (!appId) return;
const id = String(appId);
const kind = img.dataset.sweImg; // 'card' | 'list' | 'cover'
// 优先使用已验证可用的 URL(跨视图共享 + 跨会话持久化,大幅减少 fallback 尝试次数)
// v1.1.9:loadCoverUrlPersisted() 同时会预热 _sweCoverGood
loadCoverUrlPersisted();
if (_sweCoverGood.has(id)) {
const good = _sweCoverGood.get(id);
if (good) { img.src = good; return; }
}
// 根据视图类型选择 CDN 路径链
const chain = kind === 'card' ? SWE_CARD_CHAIN : (kind === 'cover' ? SWE_COVER_CHAIN : SWE_LIST_CHAIN);
// 构建完整 fallback 链:Akamai 优先(快)→ Cloudflare 兜底
const fullChain = [];
chain.forEach(function(p) { fullChain.push(SWE_CDN_AKAMAI + id + '/' + p); });
// Cloudflare 兜底
fullChain.push(SWE_CDN_CF + id + '/capsule_231x87.jpg');
fullChain.push(SWE_CDN_CF + id + '/header.jpg');
let idx = parseInt(img.dataset.fbIdx || '0', 10);
// 记录成功加载的 URL(参考 v1.58 _faCoverGood)
// v1.1.9:成功时同步写入持久化缓存
img.onload = function() {
if (img.src && img.src.indexOf('data:image') !== 0) {
_sweCoverGood.set(id, img.src);
setCoverUrlPersisted(id, img.src, kind);
}
};
const tryNext = () => {
// 跳过与当前 src 相同的 URL(初始加载已尝试过,避免重复触发或浏览器不再次报错)
while (idx < fullChain.length && img.src === fullChain[idx]) idx++;
if (idx < fullChain.length) {
// 存储"下一个要尝试的索引",确保 fallback 链正确推进(修复原版 fbIdx 始终为 0 的 bug)
img.dataset.fbIdx = String(idx + 1);
img.src = fullChain[idx++];
} else {
// CDN 链全失败 → API 兜底
fetchCapsuleFromAPI(appId).then(url => {
if (url) {
_sweCoverGood.set(id, url);
setCoverUrlPersisted(id, url, kind);
img.onerror = () => { img.onerror = null; img.src = SWE_SVG_PLACEHOLDER; img.classList.add('swe-img-failed'); };
img.src = url;
} else {
img.onerror = null;
img.src = SWE_SVG_PLACEHOLDER;
img.classList.add('swe-img-failed');
}
});
}
};
tryNext();
}
// 绑定全局图片错误事件委托(在 buildPanel 时调用一次)
function bindImgErrorHandler() {
const panel = document.getElementById('swe-panel');
if (!panel || panel.dataset.imgHandlerBound) return;
panel.dataset.imgHandlerBound = '1';
// 错误:触发 fallback 链
panel.addEventListener('error', (e) => {
const t = e.target;
if (t && t.tagName === 'IMG' && t.dataset && t.dataset.sweImg && t.dataset.appid) {
loadImageWithFallback(t);
}
}, true);
// 成功:记录可用 URL 到 good 缓存 + 持久化缓存(v1.1.9:跨会话复用最终 URL)
panel.addEventListener('load', (e) => {
const t = e.target;
if (t && t.tagName === 'IMG' && t.dataset && t.dataset.sweImg && t.dataset.appid) {
if (t.src && t.src.indexOf('data:image') !== 0) {
const id = String(t.dataset.appid);
_sweCoverGood.set(id, t.src);
setCoverUrlPersisted(id, t.src, t.dataset.sweImg);
}
}
}, true);
}
function renderWishlistToolbar() {
const sortOpts = [
['priority-asc', t('priorityAsc')], ['priority-desc', t('priorityDesc')],
['name-asc', t('nameAsc')], ['name-desc', t('nameDesc')],
['price-asc', t('priceAsc')], ['price-desc', t('priceDesc')],
['discount-desc', t('discountDesc')], ['discount-asc', t('discountAsc')],
['added-desc', t('addedDesc')], ['added-asc', t('addedAsc')],
['review-desc', t('reviewDesc')], ['review-asc', t('reviewAsc')],
];
const filterBtns = [
['all', t('allItems'), ''],
['discount', t('onlyDiscounted'), ICONS.tag],
['free', t('kpiFree'), ICONS.gift],
['ea', t('kpiEA'), ICONS.gamepad],
['soon', t('kpiSoon'), ICONS.clock],
];
const filterHtml = filterBtns.map(([key, label, icon]) =>
``
).join('');
const sortHtml = sortOpts.map(([val, label]) =>
``
).join('');
return ``;
}
// v1.1.0:工具栏右侧快速分页控件(第二行右侧,margin-left:auto 推到右边)
function renderQuickPagination() {
const { totalPages, page, items } = getWishlistPageInfo();
if (totalPages <= 1) return ''; // 只有1页时不显示
// 生成页码按钮:首页 + 当前页前后各2页 + 末页,超过范围用省略号
const pages = [];
const addPage = (p) => { if (!pages.includes(p)) pages.push(p); };
addPage(1);
for (let p = page - 2; p <= page + 2; p++) { if (p > 1 && p < totalPages) addPage(p); }
if (totalPages > 1) addPage(totalPages);
// 排序并插入省略号
pages.sort((a, b) => a - b);
const btns = [];
for (let i = 0; i < pages.length; i++) {
if (i > 0 && pages[i] - pages[i - 1] > 1) btns.push('…');
const p = pages[i];
btns.push(``);
}
return ``;
}
// v1.1.0:批量模式下的卡片类名与复选框 HTML
function batchCardClasses(item) {
const cls = [];
if (state.batchMode) cls.push('swe-batch-on');
if (state.batchMode && state.batchSelected.has(String(item.appId))) cls.push('swe-batch-selected');
return cls.length ? ' ' + cls.join(' ') : '';
}
function batchCheckHtml(item) {
if (!state.batchMode) return '';
// 复选框指示器(选中态由父级 .swe-batch-selected 控制 SVG 显隐)
return `${ICONS.checkSquare}`;
}
// v1.1.0:价格历史徽章占位槽(渲染后由 enrichPriceHistoryForVisible 异步填充)
function priceHistSlotHtml(item) {
return ``;
}
function renderGameCard(item, index) {
const url = `https://store.steampowered.com/app/${item.appId}`;
const capsule = getCardImgHtml(item.appId, item.name);
const badges = [];
if (item.discountPct > 0) badges.push(`-${item.discountPct}%`);
if (item.isFree) badges.push(`${t('kpiFree')}`);
if (item.isEarlyAccess) badges.push(`${t('kpiEA')}`);
if (item.isComingSoon) badges.push(`${t('kpiSoon')}`);
const rankCls = index === 0 ? 'rank-1' : index === 1 ? 'rank-2' : index === 2 ? 'rank-3' : '';
const rank = `#${index + 1}`;
const priceHtml = item.isFree
? `${t('priceFree')}`
: `${fmtPrice(item.finalPrice)}${item.discountPct > 0 ? `${fmtPrice(item.originalPrice)}` : ''}`;
const addedHtml = item.addedTimestamp ? `${ICONS.calendar}${fmtDateLabel(item.addedTimestamp)}` : '';
const ownedBadge = item._ownedChecked ? (item.owned ? `${ICONS.userCheck}` : '') : '';
// 整张卡片用 包裹,点击直达 Steam 商店(批量模式下点击改为勾选)
return `
${batchCheckHtml(item)}
${capsule}
${rank} ${escapeHTML(item.name)}
${ownedBadge}${badges.join('')}${priceHtml}${priceHistSlotHtml(item)}${addedHtml}
`;
}
// 封面视图渲染(竖版 library 封面 2:3 + 角标 + 名称/价格)
function renderGameCover(item, index) {
const url = `https://store.steampowered.com/app/${item.appId}`;
const capsule = getCoverImgHtml(item.appId, item.name);
const badges = [];
if (item.discountPct > 0) badges.push(`-${item.discountPct}%`);
if (item.isFree) badges.push(`${t('kpiFree')}`);
if (item.isEarlyAccess) badges.push(`${t('kpiEA')}`);
if (item.isComingSoon) badges.push(`${t('kpiSoon')}`);
const rankCls = index === 0 ? 'rank-1' : index === 1 ? 'rank-2' : index === 2 ? 'rank-3' : '';
const rankBadge = `#${index + 1}`;
const priceHtml = item.isFree
? `${t('priceFree')}`
: `${fmtPrice(item.finalPrice)}${item.discountPct > 0 ? `${fmtPrice(item.originalPrice)}` : ''}`;
const addedHtml = item.addedTimestamp ? `${ICONS.calendar}${fmtDateLabel(item.addedTimestamp)}` : '';
const ownedBadge = item._ownedChecked ? (item.owned ? `${ICONS.userCheck}` : '') : '';
return `
${batchCheckHtml(item)}
${capsule}
${ownedBadge}${badges.join('')}
${rankBadge}
${escapeHTML(item.name)}
${priceHtml}${priceHistSlotHtml(item)}${addedHtml}
`;
}
function renderGameListItem(item, index) {
const url = `https://store.steampowered.com/app/${item.appId}`;
const img = getListImgHtml(item.appId, item.name);
const badges = [];
if (item.discountPct > 0) badges.push(`-${item.discountPct}%`);
if (item.isFree) badges.push(`${t('kpiFree')}`);
if (item.isEarlyAccess) badges.push(`${t('kpiEA')}`);
if (item.isComingSoon) badges.push(`${t('kpiSoon')}`);
const rankCls = index === 0 ? 'rank-1' : index === 1 ? 'rank-2' : index === 2 ? 'rank-3' : '';
const rank = `#${index + 1}`;
const priceHtml = item.isFree ? t('priceFree') : `${fmtPrice(item.finalPrice)}${item.discountPct > 0 ? ` ${fmtPrice(item.originalPrice)}` : ''}`;
const addedHtml = item.addedTimestamp ? `· ${fmtDate(item.addedTimestamp)}` : '';
const ownedBadge = item._ownedChecked ? (item.owned ? `${ICONS.userCheck}` : '') : '';
// 列表项也是 整行可点击(批量模式下点击改为勾选)
return `
${batchCheckHtml(item)}
${img}
${rank} ${escapeHTML(item.name)}
${ownedBadge}${badges.join('')} · ${priceHtml} ${priceHistSlotHtml(item)} ${addedHtml}
${ICONS.chevronRight}
`;
}
// v1.1.0:批量管理工具栏(全选/移除选中/退出批量)
function renderBatchToolbar() {
if (!state.batchMode) return '';
const allFilteredIds = state.filtered.map(it => String(it.appId));
const allSelected = allFilteredIds.length > 0 && allFilteredIds.every(id => state.batchSelected.has(id));
const selCount = state.batchSelected.size;
return `
${t('batchSelectedCount', { count: selCount })}
`;
}
// v1.1.0:提取分页计算为共享函数,供工具栏快速分页和列表底部分页共用
function getWishlistPageInfo() {
const items = state.filtered;
const perPage = state.viewMode === 'card' ? CONFIG.ITEMS_PER_PAGE_CARD
: state.viewMode === 'cover' ? CONFIG.ITEMS_PER_PAGE_COVER
: CONFIG.ITEMS_PER_PAGE_LIST;
const totalPages = Math.max(1, Math.ceil(items.length / perPage));
if (state.page > totalPages) state.page = 1;
if (state.page < 1) state.page = 1;
return { items, perPage, totalPages, page: state.page };
}
function renderWishlistList() {
const { items, perPage, totalPages, page } = getWishlistPageInfo();
const start = (page - 1) * perPage;
const pageItems = items.slice(start, start + perPage);
const batchToolbar = renderBatchToolbar();
if (items.length === 0) return batchToolbar + `${t('noData')}
`;
let itemsHtml;
if (state.viewMode === 'card') {
itemsHtml = `${pageItems.map((item, i) => renderGameCard(item, start + i)).join('')}
`;
} else if (state.viewMode === 'cover') {
itemsHtml = `${pageItems.map((item, i) => renderGameCover(item, start + i)).join('')}
`;
} else {
itemsHtml = `${pageItems.map((item, i) => renderGameListItem(item, start + i)).join('')}
`;
}
const pagination = totalPages > 1 ? `` : '';
return batchToolbar + itemsHtml + pagination;
}
// v1.1.1: 愿望单类别视图——左右分栏(左侧类别列表 / 右侧游戏列表 + 视图切换)
function renderWishlistCategoryView() {
const catDist = computeCategoryDist();
// 无类别数据时回退到普通列表
if (!catDist || !catDist.hasCategories) {
return `${renderWishlistList()}
`;
}
// 默认选中第一个类别(同步更新 filtered 以确保首次渲染正确)
if (!state.categoryFilter || state.categoryFilter === '__all__') {
state.categoryFilter = catDist.rows[0].id;
state.filtered = getFilteredGames();
}
// 构建左侧类别列表
const allCats = [
{ id: '__all__', label: t('allItems'), count: catDist.total, color: '#8b5cf6' },
...catDist.rows.map(r => ({ id: r.id, label: r.label, count: r.count, color: r.color })),
];
if (catDist.uncatCount > 0) {
allCats.push({ id: '__uncat__', label: t('catUncategorized'), count: catDist.uncatCount, color: '#64748b' });
}
const sidebarHtml = allCats.map(c => `
${escapeHTML(c.label)}
${c.count}
`).join('');
// 右侧头部 + 视图切换 + 游戏列表
const currentCat = allCats.find(c => c.id === state.categoryFilter) || allCats[0];
const filteredCount = state.filtered.length;
const viewSwitchHtml = `
`;
const headerHtml = `
${escapeHTML(currentCat.label)}
${filteredCount}
${viewSwitchHtml}
`;
return `
${headerHtml}
${renderWishlistList()}
`;
}
function renderWishlist() {
if (state.categoryView) {
return renderWishlistToolbar() + renderWishlistCategoryView();
}
return renderWishlistToolbar() + `${renderWishlistList()}
`;
}
// ============================================================
// v1.0.6 AI 犀利点评(参考 steam-game-library-viewer v2.4.5)
// ============================================================
const AI_DEFAULT_URL = 'https://api.deepseek.com/v1/chat/completions';
const AI_DEFAULT_MODEL = 'deepseek-v4-pro';
const aiStorage = {
getApiUrl: () => GM_getValue('swe_ai_api_url', AI_DEFAULT_URL),
setApiUrl: v => GM_setValue('swe_ai_api_url', v),
getApiKey: () => GM_getValue('swe_ai_api_key', ''),
setApiKey: v => GM_setValue('swe_ai_api_key', v),
getModel: () => GM_getValue('swe_ai_model', AI_DEFAULT_MODEL),
setModel: v => GM_setValue('swe_ai_model', v),
};
// AI 分析状态(内存态;sig 标识数据版本,数据变化后旧结论自动失效)
const aiInsight = { result: null, loading: false, error: '', truncated: false, sig: '' };
function aiDataSig() {
const items = state.items;
if (!items.length) return '0';
return items.length + '_' + items[0].appId + '_' + items[items.length - 1].appId;
}
// 全局报错:toast 弹窗 + 控制台,行内错误由调用方另行渲染
function aiGlobalError(msg) {
console.error('[SWE][AI]', msg);
toast('AI: ' + msg, 'error', 5000);
}
// 统一 AI API POST 调用(OpenAI 兼容格式),拒绝时给出可读错误
function callAiApi(prompt, { temperature = 0.7, timeout = 90000, maxTokens = 2048 } = {}) {
const apiKey = aiStorage.getApiKey();
if (!apiKey) return Promise.reject(new Error(t('aiNotConfigured')));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST', url: aiStorage.getApiUrl(), timeout,
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
data: JSON.stringify({ model: aiStorage.getModel(), messages: [{ role: 'user', content: prompt }], temperature, max_tokens: maxTokens }),
onload(resp) {
try {
const respData = JSON.parse(resp.responseText);
if (resp.status < 200 || resp.status >= 300) {
reject(new Error(`HTTP ${resp.status}: ${respData?.error?.message || resp.responseText.slice(0, 120)}`));
return;
}
const choice = respData?.choices?.[0] || {};
const content = choice.message?.content || '';
if (!content) { reject(new Error('AI 返回为空')); return; }
resolve({ content, truncated: choice.finish_reason === 'length' });
} catch (e) { reject(new Error('AI 响应解析失败: ' + e.message)); }
},
onerror: () => reject(new Error('AI API 网络错误')),
ontimeout: () => reject(new Error('AI API 请求超时')),
});
});
}
// 修复被截断的 JSON:关闭未闭合字符串 → 裁到最后安全边界 → 去尾逗号 → 补齐括号
function repairTruncatedJson(str) {
let s = str, inStr = false, esc = false;
const stk = [];
let lastSafe = -1;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (esc) { esc = false; continue; }
if (c === '\\') { esc = true; continue; }
if (c === '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c === '{' || c === '[') stk.push(c);
else if (c === '}' || c === ']') {
if (stk.length) {
stk.pop();
if (stk.length > 0 && stk[stk.length - 1] === '[') lastSafe = i + 1;
}
}
}
if (inStr) s += '"';
s = s.replace(/\s+$/, '');
if (s.length && !/^[}\],]$/.test(s[s.length - 1]) && lastSafe >= 0) s = s.slice(0, lastSafe);
s = s.replace(/,\s*$/, '');
inStr = false; esc = false;
const stk2 = [];
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (esc) { esc = false; continue; }
if (c === '\\') { esc = true; continue; }
if (c === '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c === '{' || c === '[') stk2.push(c);
else if (c === '}' || c === ']') stk2.pop();
}
while (stk2.length) s += stk2.pop() === '{' ? '}' : ']';
return s;
}
// 从 AI 输出中稳健解析 JSON(去代码围栏 → 截取花括号范围 → 失败则修复截断重试)
function safeParseAiJson(content) {
let s = String(content || '').trim();
s = s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start < 0) throw new Error('AI 返回中未找到 JSON');
s = end > start ? s.slice(start, end + 1) : s.slice(start);
try { return JSON.parse(s); } catch (e) { return JSON.parse(repairTruncatedJson(s)); }
}
// 构造愿望单犀利点评 prompt(数据全部来自本地统计,语言随界面 locale)
function buildWishlistPrompt() {
const s = computeStats();
const isZh = locale !== 'en';
const cur = state.currency;
const curId = cur ? cur.id : '';
const priceDist = computePriceDistribution();
const noPrice = priceDist.find(r => r.key === 'noPrice')?.count || 0;
const upcoming = computeUpcoming();
const topDisc = computeTopDiscount().slice(0, 5).map(g => `${g.name}(-${g.discountPct}%, ${fmtPrice(g.finalPrice)})`).join(', ');
const topExpensive = [...state.items].filter(i => !i.isFree && i.finalPrice > 0)
.sort((a, b) => b.finalPrice - a.finalPrice).slice(0, 5)
.map(i => `${i.name}(${fmtPrice(i.finalPrice)})`).join(', ');
const tagsStr = s.topTags.slice(0, 12).map(([name, c]) => `${name}(${c})`).join(', ');
const typesStr = Object.entries(s.types).map(([k, v]) => `${k}(${v})`).join(', ');
const upcomingStr = upcoming
? `${upcoming.total}款待上市(30天内${upcoming.rows[0].count}/90天内${upcoming.rows[1].count}/更远${upcoming.rows[2].count}/未定${upcoming.rows[3].count})${upcoming.nearest ? ', 最近发售: ' + upcoming.nearest.name + ' ' + upcoming.nearest.date : ''}`
: '无待上市游戏';
// v1.1.3: 即将上市 Top 5(供 AI 点评引用具体游戏名+发行日)
const upcomingTopStr = upcoming && upcoming.topList && upcoming.topList.length > 0
? upcoming.topList.map(d => {
if (d.isTBA) return `${d.name}(TBA)`;
if (d.daysLeft === 0) return `${d.name}(今天发售, ${d.releaseDate})`;
return `${d.name}(${d.releaseDate}, 还有${d.daysLeft}天)`;
}).join(', ')
: '';
return `# 角色
你是一位游戏消费行为分析专家和 Steam 愿望单评论家,以犀利幽默、一针见血的风格点评玩家的愿望单。
# 任务
基于玩家的 Steam 愿望单数据,从五个维度生成深度点评。语言要犀利但不恶意,观点必须有数据支撑,避免空话套话。${isZh ? '全程使用中文回答。' : 'Answer entirely in English.'}
# 输入数据
- 愿望单总数: ${s.n} 款
- 总价值: ${fmtPrice(s.totalValue)} ${curId}(原价 ${fmtPrice(s.totalOrig)})
- 均价: ${fmtPrice(s.avgPrice)} / 中位数: ${fmtPrice(s.medianPrice)}
- 打折中: ${s.discounted} 款(占比 ${(s.discountedRate * 100).toFixed(0)}%,平均折扣 ${s.avgDiscount.toFixed(0)}%,最大折扣 ${s.maxDiscount}%)
- 免费游戏: ${s.free} 款 / 无价格(未定价): ${noPrice} 款
- 抢先体验: ${s.ea} 款 / 即将推出: ${s.soon} 款
- 待上市详情: ${upcomingStr}
${upcomingTopStr ? '- 即将上市 Top 5: ' + upcomingTopStr : ''}
- 收藏时间跨度: ${s.yearSpan} 年(最早添加 ${s.earliest || '未知'})
- 热门标签: ${tagsStr || '无'}
- 类型分布: ${typesStr}
- 折扣最深 Top5: ${topDisc || '无'}
- 最贵游戏 Top5: ${topExpensive || '无'}
# 输出格式
严格按以下 JSON 格式输出,不要输出任何其他文字:
{
"oneLiner": "一句话犀利总结,30字以内,直击要害",
"sections": [
{"title": "${t('aiSecOverview')}", "content": "2-4句,点评愿望单规模、收藏习惯、时间跨度"},
{"title": "${t('aiSecSpending')}", "content": "2-4句,分析总价值、价格结构、折扣敏感度、免费/无价格占比"},
{"title": "${t('aiSecPreference')}", "content": "2-4句,从标签和类型分布推断游戏品味与偏好"},
{"title": "${t('aiSecHighlights')}", "content": "2-4句,先肯定亮点再点出槽点,犀利但建设性"},
{"title": "${t('aiSecAdvice')}", "content": "2-4句,给出具体的入手/清理/等等党建议"}
]
}`;
}
// 触发 AI 分析(防重入;未配置 Key 时全局报错并打开设置)
async function triggerAiInsight() {
if (aiInsight.loading) return;
if (!aiStorage.getApiKey()) {
aiGlobalError(t('aiNotConfigured'));
openSettings();
return;
}
aiInsight.loading = true;
aiInsight.error = '';
aiInsight.result = null;
aiInsight.truncated = false;
aiInsight.sig = aiDataSig();
updateAiInsightUI();
try {
const { content, truncated } = await callAiApi(buildWishlistPrompt());
const result = safeParseAiJson(content);
if (!result.oneLiner || !Array.isArray(result.sections) || !result.sections.length) {
throw new Error('AI 返回结构不完整');
}
aiInsight.result = result;
aiInsight.truncated = truncated;
} catch (e) {
aiInsight.error = e.message || 'AI 分析失败';
aiGlobalError(aiInsight.error);
} finally {
aiInsight.loading = false;
updateAiInsightUI();
}
}
// AI 卡片 HTML(分析页内嵌,位于热门标签容器之下)
function renderAiInsightHtml() {
const configured = !!aiStorage.getApiKey();
const model = aiStorage.getModel();
const stale = aiInsight.result && aiInsight.sig !== aiDataSig(); // 数据已变化,旧结论失效
const btnLabel = aiInsight.loading ? t('aiAnalyzing') : (aiInsight.result ? t('aiReanalyze') : t('aiStart'));
const spinSvg = ``;
let bodyHtml;
if (aiInsight.loading) {
bodyHtml = `
${ICONS.refresh}
${t('aiAnalyzing')}
${t('aiEmptyHint')}
`;
} else if (aiInsight.error) {
bodyHtml = `
⚠ ${t('aiFailed')}: ${escapeHTML(aiInsight.error)}
`;
} else if (aiInsight.result && !stale) {
const r = aiInsight.result;
const colors = ['#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ec4899'];
bodyHtml = `${aiInsight.truncated ? `⚠ ${t('aiTruncated')}
` : ''}
${ICONS.sparkles}
${escapeHTML(r.oneLiner)}
${r.sections.map((sec, i) => `
${ICONS.sparkles}${escapeHTML(sec.title || '')}
${escapeHTML(sec.content || '')}
`).join('')}`;
} else {
bodyHtml = `
${ICONS.sparkles}
${t('aiSubtitle')}
${t('aiEmptyHint')}
${!configured ? `
⚠ ${t('aiNotConfigured')}
` : ''}
`;
}
return `
${ICONS.sparkles} ${t('aiTitle')}${escapeHTML(model)}
${bodyHtml}
`;
}
// 数据就绪后局部刷新 AI 卡片(元素不存在说明不在分析页,跳过)
function updateAiInsightUI() {
const el = document.getElementById('swe-ai-insight');
if (!el) return;
el.outerHTML = renderAiInsightHtml();
bindAiEvents();
}
function bindAiEvents() {
const trigger = document.getElementById('swe-ai-trigger');
if (trigger) trigger.addEventListener('click', triggerAiInsight);
const goto = document.getElementById('swe-ai-goto-settings');
if (goto) goto.addEventListener('click', openSettings);
}
// ============================================================
// v1.0.6 设置浮窗(AI 配置)
// ============================================================
let settingsOverlayEl = null;
function ensureSettings() {
if (settingsOverlayEl) return;
settingsOverlayEl = document.createElement('div');
settingsOverlayEl.className = 'swe-settings-overlay';
settingsOverlayEl.innerHTML = `
`;
document.body.appendChild(settingsOverlayEl);
settingsOverlayEl.querySelector('.swe-settings-close').addEventListener('click', closeSettings);
settingsOverlayEl.addEventListener('click', e => { if (e.target === settingsOverlayEl) closeSettings(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && settingsOverlayEl.classList.contains('swe-show')) closeSettings(); });
}
function openSettings() {
ensureSettings();
renderSettingsBody();
settingsOverlayEl.classList.add('swe-show');
}
function closeSettings() {
if (settingsOverlayEl) settingsOverlayEl.classList.remove('swe-show');
}
function renderSettingsBody() {
const body = settingsOverlayEl.querySelector('#swe-settings-body');
if (!body) return;
body.innerHTML = `
${ICONS.sparkles} ${t('aiSectionTitle')}
${t('aiApiKeyHelp')}
`;
body.querySelector('#swe-settings-save').addEventListener('click', () => {
aiStorage.setApiKey(body.querySelector('#swe-set-ai-key').value.trim());
aiStorage.setModel(body.querySelector('#swe-set-ai-model').value.trim() || AI_DEFAULT_MODEL);
aiStorage.setApiUrl(body.querySelector('#swe-set-ai-url').value.trim() || AI_DEFAULT_URL);
const status = body.querySelector('#swe-settings-status');
status.textContent = '✓ ' + t('saved');
toast(t('saved'), 'success');
setTimeout(() => { closeSettings(); updateAiInsightUI(); }, 600);
});
}
// ============================================================
// 分析仪表标签页
// ============================================================
function renderAnalytics() {
const s = computeStats();
if (s.n === 0) return ``;
const TYPE_LABELS = { game: t('typeGame'), dlc: t('typeDLC'), software: t('typeSoftware'), demo: 'Demo', series: 'Series', video: 'Video', music: 'Music', other: t('typeOther') };
const typeData = Object.entries(s.types).map(([k, v]) => ({
label: TYPE_LABELS[k] || k,
count: v, color: k === 'game' ? '#8b5cf6' : k === 'dlc' ? '#3b82f6' : k === 'software' ? '#06b6d4' : '#64748b'
}));
const platformData = [
{ label: 'Windows', count: s.platforms.windows, color: '#3b82f6' },
{ label: 'macOS', count: s.platforms.mac, color: '#a855f7' },
{ label: 'Linux', count: s.platforms.linux, color: '#06b6d4' },
];
const reviewData = computeReviewDist();
const priorityDist = computePriorityDist();
const upcoming = computeUpcoming();
// 评分分布:5 列柱状
const reviewViz = renderReviewViz(reviewData);
// 优先级分布:水平柱状
const priorityViz = renderPriorityViz(priorityDist);
// 类型 / 平台:donut 居中 + 图例底部 2 列
const typeViz = renderDonutWithLegend(typeData, t('total'), s.n);
const platformViz = renderDonutWithLegend(platformData, t('total'), s.platforms.windows + s.platforms.mac + s.platforms.linux);
// v1.1.3: 待上市游戏——全宽双栏(左柱状图 + 右 Top 5 列表),方便一眼看出最近要上市的游戏
const upcomingViz = upcoming ? (() => {
// 左栏:4 桶水平柱状图(含最近发售提示)
const barViz = `
${upcoming.rows.map(d => `
${escapeHTML(d.label)}
${d.count}
`).join('')}
${upcoming.nearest ? `
${ICONS.clock}${escapeHTML(upcoming.nearest.name)} · ${upcoming.nearest.date}
` : ''}
`;
// 右栏:Top 5 列表(按发行日期升序,封面 + 名称 + 倒计时)
const topList = upcoming.topList || [];
const topViz = topList.length === 0
? `${t('noData')}
`
: `${topList.map((d, i) => {
let countdownHtml;
if (d.isTBA) {
countdownHtml = `
${t('upTbaLabel')}`;
} else if (d.daysLeft === 0) {
countdownHtml = `
${t('upToday')}`;
} else if (d.daysLeft <= 30) {
countdownHtml = `
${t('upDay', { n: d.daysLeft })}`;
} else if (d.daysLeft <= 90) {
countdownHtml = `
${t('upDay', { n: d.daysLeft })}`;
} else {
countdownHtml = `
${t('upDay', { n: d.daysLeft })}`;
}
const _upGoodUrl = _sweCoverGood.get(String(d.appId));
const imgUrl = _upGoodUrl || (SWE_CDN_AKAMAI + d.appId + '/' + SWE_LIST_CHAIN[0]);
return `
${i + 1}
${escapeHTML(d.name)}
${d.releaseDate ? `${escapeHTML(d.releaseDate)}` : ''}${countdownHtml}
`;
}).join('')}
`;
return `
${barViz}
${ICONS.trending} ${t('upTopTitle')}
${topViz}
`;
})() : `${t('noData')}
`;
// 标签云
const tagGrid = renderTagGrid(s.topTags);
// v1.1.2: 我的类别分布——两栏全宽(左柱状图 + 右环形图)
const catDist = computeCategoryDist();
const categoryBarViz = (() => {
if (!catDist || !catDist.hasCategories) return `${t('catNoCategory')}
`;
const allRows = [...catDist.rows];
if (catDist.uncatCount > 0) allRows.push(catDist.uncat);
const maxCount = Math.max(1, ...allRows.map(r => r.count));
const renderCatCard = (d) => {
const isUncat = d.id === '__uncat__';
const nameStyle = isUncat ? 'font-style:italic;color:var(--swe-text-2)' : '';
return `
${d.totalValue > 0 ? `${ICONS.dollar}${fmtPriceCompact(d.totalValue)}` : ''}
${d.discountCount > 0 ? `${ICONS.tag}${d.discountCount}` : ''}
${d.comingSoonCount > 0 ? `${ICONS.clock}${d.comingSoonCount}` : ''}
${d.inLibraryCount > 0 ? `${ICONS.userCheck}${d.inLibraryCount}` : ''}
`;
};
return `${allRows.map(renderCatCard).join('')}
`;
})();
// v1.1.2: 类别占比环形图
const categoryDonutViz = (() => {
if (!catDist || !catDist.hasCategories) return `${t('catNoCategory')}
`;
const allRows = [...catDist.rows];
if (catDist.uncatCount > 0) allRows.push(catDist.uncat);
const donutData = allRows.map(r => ({ label: r.label, count: r.count, color: r.color }));
return renderDonutWithLegend(donutData, t('chartCategory'), catDist.total);
})();
return `
${ICONS.gamepad} ${t('chartType')}
${typeViz}
${ICONS.tag} ${t('chartPlatform')}
${platformViz}
${ICONS.barChart} ${t('chartReview')}
${reviewViz}
${ICONS.dashboard} ${t('chartPriority')}
${priorityViz}
${ICONS.tag} ${t('chartCategory')}${catDist && catDist.hasCategories ? catDist.rows.length : 0}
${categoryBarViz}
${categoryDonutViz}
${ICONS.calendar} ${t('chartUpcoming')}${upcoming ? upcoming.total : 0}
${upcomingViz}
${ICONS.tag} ${t('chartTags')}${s.topTags.length}
${tagGrid}
${renderHistLowSection()}
${renderAiInsightHtml()}
`;
}
// 分析页专用:donut 居中 + 图例底部 2 列网格
function renderDonutWithLegend(data, centerLbl, totalOverride) {
const total = totalOverride != null ? totalOverride : data.reduce((s, d) => s + d.count, 0);
if (total === 0) return `${t('noData')}
`;
const size = 130, thick = 16;
const r = (size - thick) / 2;
const cx = size / 2, cy = size / 2;
const circ = 2 * Math.PI * r;
let offset = 0;
const arcs = data.map(d => {
const frac = total ? d.count / total : 0;
const dash = frac * circ;
const arc = ``;
offset += dash;
return arc;
}).join('');
const legendCols = data.length <= 3 ? 1 : 2;
const legend = data.map(d => {
const pctVal = total ? ((d.count / total) * 100).toFixed(0) : 0;
return `
${escapeHTML(d.label)}
${d.count} · ${pctVal}%
`;
}).join('');
return ``;
}
// 分析页专用:评分分布 5 列柱状
function renderReviewViz(data) {
const max = Math.max(1, ...data.map(x => x.count));
return `
${data.map(d => `
${d.count}
${escapeHTML(d.label)}
`).join('')}
`;
}
// 分析页专用:优先级横向柱状
function renderPriorityViz(data) {
const max = Math.max(1, ...data.map(x => x.count));
return `
${data.map(d => `
${escapeHTML(d.label)}
${d.count}
`).join('')}
`;
}
// 标签网格:按列数自适应填充,撑满容器
function renderTagGrid(tags) {
if (tags.length === 0) return `${t('noData')}
`;
const max = Math.max(...tags.map(tag => tag[1]));
const colors = ['#8b5cf6', '#3b82f6', '#06b6d4', '#a855f7', '#6366f1', '#ec4899', '#10b981', '#f59e0b', '#fb923c', '#fbbf24', '#22d3ee', '#34d399', '#f472b6', '#a78bfa', '#60a5fa'];
return `${tags.map((tag, i) => {
const w = tag[1] / max;
const size = 11 + w * 6;
const color = colors[i % colors.length];
return `
${escapeHTML(tag[0])}
${tag[1]}
`;
}).join('')}
`;
}
// ============================================================
// 趋势标签页
// ============================================================
function renderTrends() {
const s = computeStats();
if (s.n === 0) return ``;
// 根据模式选择数据
let trendData, trendTitle;
if (trendMode === 'quarter') {
trendData = computeQuarterTrend();
trendTitle = t('chartTrendTitle') + ' (' + t('trendModeQuarter') + ')';
} else if (trendMode === 'month') {
trendData = computeMonthTrend();
trendTitle = t('chartTrendTitle') + ' (' + t('trendModeMonth') + ')';
} else {
trendData = computeYearTrend().map(y => ({ label: String(y.year), count: y.count }));
trendTitle = t('chartTrendTitle') + ' (' + t('trendModeYear') + ')';
}
const trendChart = renderTrendChart(trendData);
const heatmap = computeHeatmap();
const timeline = computeTimeline();
// 切换按钮组
const toggleHtml = `
`;
// 时间线:分组卡片式设计
const timelineHtml = timeline.length > 0 ? `
${ICONS.clock} ${t('chartTimeline')}
${timeline.map(group => {
const dateStr = group.date.toLocaleDateString(locale === 'en' ? 'en-US' : 'zh-CN', { year: 'numeric', month: 'short', day: 'numeric' });
const today = new Date();
today.setHours(0, 0, 0, 0);
const gDate = new Date(group.date);
gDate.setHours(0, 0, 0, 0);
const diffDays = Math.round((today - gDate) / 86400000);
let relLabel = '';
if (diffDays === 0) relLabel = locale === 'en' ? 'Today' : '今天';
else if (diffDays === 1) relLabel = locale === 'en' ? 'Yesterday' : '昨天';
else if (diffDays > 0 && diffDays < 7) relLabel = diffDays + (locale === 'en' ? 'd ago' : '天前');
const itemsHtml = group.items.map(item => {
const img = getListImgHtml(item.appId, item.name);
const badges = [];
if (item.discountPct > 0) badges.push(`
-${item.discountPct}%`);
if (item.isFree) badges.push(`
${t('kpiFree')}`);
return `
${img}
${escapeHTML(item.name)}
${badges.join('')}#${item.appId}
`;
}).join('');
return `
`;
}).join('')}
` : '';
return `
${trendChart}
${ICONS.calendar} ${t('chartHeatmap')}
${renderHeatmap(heatmap)}
${timelineHtml}
`;
}
// ============================================================
// 标签页渲染调度
// ============================================================
function renderActiveTab() {
invalidateStats();
const body = document.getElementById('swe-body');
if (!body) return;
let html = '';
switch (activeTab) {
case 'overview': html = renderOverview(); break;
case 'wishlist': html = renderWishlist(); break;
case 'analytics': html = renderAnalytics(); break;
case 'trends': html = renderTrends(); break;
}
body.innerHTML = html;
// v2.2.27:异步加载游戏中文名
body.querySelectorAll('[data-swe-appid]').forEach(el => {
loadGameZhName(el, el.getAttribute('data-swe-appid'), el.textContent);
});
bindTabEvents();
// v1.1.0:愿望单页渲染后异步填充价格历史徽章(不阻塞 UI)
if (activeTab === 'wishlist') {
enrichPriceHistoryForVisible().catch(e => console.warn('[SWE] price history enrich failed:', e));
}
// v1.2.0:分析页渲染后绑定史低扫描按钮
if (activeTab === 'analytics') {
const scanBtn = document.getElementById('swe-histlow-scan');
if (scanBtn) scanBtn.addEventListener('click', () => scanHistoricalLows());
const aiBtn = document.getElementById('swe-histlow-ai');
if (aiBtn) aiBtn.addEventListener('click', () => triggerAiPricePrediction());
}
}
// ============================================================
// 事件绑定
// ============================================================
let searchDebounce = null;
function bindTabEvents() {
const searchInput = document.querySelector('.swe-search');
if (searchInput) {
searchInput.addEventListener('input', () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
state.search = searchInput.value.toLowerCase().trim();
applyFilters();
}, CONFIG.DEBOUNCE);
});
}
const sortSelect = document.querySelector('.swe-sort-select');
if (sortSelect) {
sortSelect.addEventListener('change', () => {
state.sortBy = sortSelect.value;
applyFilters();
});
}
document.querySelectorAll('[data-filter]').forEach(btn => {
btn.addEventListener('click', () => {
state.gameFilter = btn.dataset.filter;
applyFilters();
});
});
document.querySelectorAll('[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
state.viewMode = btn.dataset.view;
renderActiveTab();
});
});
// v1.1.1:类别视图开关
document.querySelectorAll('[data-cat-view-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
state.categoryView = btn.dataset.catViewToggle === 'true';
if (state.categoryView) {
// 开启时默认选中第一个类别
const catDist = computeCategoryDist();
if (catDist && catDist.hasCategories) {
state.categoryFilter = catDist.rows[0].id;
}
} else {
state.categoryFilter = null;
}
state.page = 1;
applyFilters();
});
});
// v1.1.1:类别项点击——切换筛选
document.querySelectorAll('[data-cat-id]').forEach(item => {
item.addEventListener('click', () => {
state.categoryFilter = item.dataset.catId;
state.page = 1;
applyFilters();
});
});
const prevBtn = document.getElementById('swe-prev-page');
if (prevBtn) prevBtn.addEventListener('click', () => { if (state.page > 1) { state.page--; renderActiveTab(); } });
const nextBtn = document.getElementById('swe-next-page');
if (nextBtn) nextBtn.addEventListener('click', () => { state.page++; renderActiveTab(); });
// v1.1.0:工具栏快速分页按钮
document.querySelectorAll('.swe-quick-page, .swe-quick-nav').forEach(btn => {
if (btn.disabled) return;
btn.addEventListener('click', () => {
const p = parseInt(btn.dataset.quickPage, 10);
if (!isNaN(p) && p >= 1) { state.page = p; renderActiveTab(); }
});
});
// 趋势页:切换按钮
document.querySelectorAll('[data-trend-mode]').forEach(btn => {
btn.addEventListener('click', () => {
trendMode = btn.dataset.trendMode;
renderActiveTab();
});
});
// 分析页:AI 犀利点评触发按钮
bindAiEvents();
// 趋势页:热力图默认滚动到最右侧,直接展示最新数据
const scrollHmRight = () => requestAnimationFrame(() => {
const s = document.querySelector('.swe-heatmap-scroll');
if (s) s.scrollLeft = s.scrollWidth;
});
// 趋势页:热力图年份选择器——点击切换单年/全部视图(视图 HTML 已预计算)
const hmFilter = document.querySelector('.swe-hm-year-filter');
const hmBody = document.querySelector('.swe-heatmap-body');
if (hmFilter && hmBody) {
hmFilter.addEventListener('click', e => {
const btn = e.target.closest('.swe-hm-year-btn');
if (!btn || !heatmapViews) return;
const view = heatmapViews[btn.dataset.year];
if (!view) return;
hmFilter.querySelectorAll('.swe-hm-year-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
hmBody.innerHTML = view;
scrollHmRight();
});
}
scrollHmRight();
// 趋势页:数据点 hover tooltip
const tooltip = document.getElementById('swe-trend-tooltip');
if (tooltip) {
const chartWrap = tooltip.parentElement;
document.querySelectorAll('.swe-trend-dot').forEach(dot => {
dot.addEventListener('mouseenter', () => {
const label = dot.getAttribute('data-label');
const count = dot.getAttribute('data-count');
tooltip.innerHTML = `${escapeHTML(label)}: ${count}`;
const rect = chartWrap.getBoundingClientRect();
const dotRect = dot.getBoundingClientRect();
tooltip.style.left = (dotRect.left - rect.left + dotRect.width / 2) + 'px';
tooltip.style.top = (dotRect.top - rect.top) + 'px';
tooltip.classList.add('visible');
});
dot.addEventListener('mouseleave', () => {
tooltip.classList.remove('visible');
});
});
}
// v1.1.0:仅显示已拥有 筛选开关
document.querySelectorAll('[data-owned-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
state.onlyOwned = btn.dataset.ownedToggle === 'true';
applyFilters();
});
});
// v1.1.0:批量管理模式开关
document.querySelectorAll('[data-batch-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
state.batchMode = btn.dataset.batchToggle === 'true';
if (!state.batchMode) state.batchSelected = new Set();
renderActiveTab();
});
});
// v1.1.0:退出批量管理
document.querySelectorAll('[data-batch-exit]').forEach(btn => {
btn.addEventListener('click', () => {
state.batchMode = false;
state.batchSelected = new Set();
renderActiveTab();
});
});
// v1.1.0:全选 / 取消全选(基于当前筛选结果)
document.querySelectorAll('[data-batch-select-all]').forEach(btn => {
btn.addEventListener('click', () => {
const selectAll = btn.dataset.batchSelectAll === 'true';
if (selectAll) {
state.filtered.forEach(it => state.batchSelected.add(String(it.appId)));
} else {
state.filtered.forEach(it => state.batchSelected.delete(String(it.appId)));
}
renderActiveTab();
});
});
// v1.1.0:移除选中(触发确认面板 + 批量移除流程)
document.querySelectorAll('[data-batch-remove]').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.disabled || state.batchRemoving) return;
performBatchRemove().catch(e => {
console.error('[SWE] batch remove error:', e);
toast(t('batchRemoveSingleFail'), 'error');
});
});
});
// v1.1.0:批量模式下点击卡片/列表项切换选中(拦截默认跳转)
document.querySelectorAll('.swe-game-card[data-appid], .swe-cover-card[data-appid], .swe-list-item[data-appid]').forEach(el => {
el.addEventListener('click', e => {
if (!state.batchMode) return;
e.preventDefault();
e.stopPropagation();
const id = String(el.dataset.appid);
if (state.batchSelected.has(id)) state.batchSelected.delete(id);
else state.batchSelected.add(id);
// 局部更新类名,避免整页重渲染打断交互
el.classList.toggle('swe-batch-selected', state.batchSelected.has(id));
// 更新批量工具栏的计数与全选状态
const tb = document.querySelector('.swe-batch-toolbar');
if (tb) {
const allIds = state.filtered.map(it => String(it.appId));
const allSelected = allIds.length > 0 && allIds.every(x => state.batchSelected.has(x));
const countEl = tb.querySelector('.swe-batch-count');
if (countEl) countEl.textContent = t('batchSelectedCount', { count: state.batchSelected.size });
const allBtn = tb.querySelector('[data-batch-select-all]');
if (allBtn) {
allBtn.dataset.batchSelectAll = String(!allSelected);
allBtn.innerHTML = allSelected ? ICONS.checkDouble + ' ' + t('unselectAll') : ICONS.checkSquare + ' ' + t('selectAll');
}
const rmBtn = tb.querySelector('[data-batch-remove]');
if (rmBtn) rmBtn.disabled = state.batchSelected.size === 0;
}
});
});
}
// ============================================================
// 导出功能
// ============================================================
function exportCSV() {
const items = getFilteredGames();
if (items.length === 0) { toast(t('exportNoData'), 'error'); return; }
const headers = [t('csvAppId'), t('csvName'), t('csvPriority'), t('csvAddedDate'), t('csvFinalPrice'), t('csvOriginalPrice'), t('csvDiscount'), t('csvReviewScore'), t('csvReviewPercent'), t('csvIsEarlyAccess'), t('csvIsFree'), t('csvIsComingSoon'), t('csvReleaseDate'), t('csvStoreUrl'), t('csvType'), t('csvTags'), t('csvPlatforms'), t('csvCapsule'), t('csvCategoryNames')];
const rows = items.map(i => [
i.appId, i.name, i.priority || '', i.addedTimestamp ? fmtDate(i.addedTimestamp) : '',
i.finalPrice, i.originalPrice, i.discountPct,
i.reviewScore || '', i.reviewPercent || '',
i.isEarlyAccess ? 'Y' : '', i.isFree ? 'Y' : '', i.isComingSoon ? 'Y' : '',
i.releaseDate || '', `https://store.steampowered.com/app/${i.appId}`,
i.type || '', (i.tags || []).map(tg => tg.name).join('; '),
i.platforms ? Object.entries(i.platforms).filter(([, v]) => v).map(([k]) => k).join(';') : '',
i.capsule || '', (i.categoryNames || []).join(';')
].map(v => {
const s = String(v ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}).join(','));
const csv = '\ufeff' + headers.join(',') + '\n' + rows.join('\n');
downloadBlob(csv, `wishlist_${steamId}_${Date.now()}.csv`, 'text/csv;charset=utf-8');
toast(t('exportSuccess') + ` (${items.length} ${t('games')})`, 'success');
}
function exportJSON() {
const items = getFilteredGames();
if (items.length === 0) { toast(t('exportNoData'), 'error'); return; }
const data = {
exportedAt: new Date().toISOString(),
steamId, currency: state.currency?.id || null,
totalItems: items.length,
stats: computeStats(),
items: items.map(i => ({
appId: i.appId, name: i.name, priority: i.priority || 0,
addedDate: i.addedTimestamp ? fmtDate(i.addedTimestamp) : null,
addedTimestamp: i.addedTimestamp || null,
finalPrice: i.finalPrice, originalPrice: i.originalPrice,
discountPct: i.discountPct, isFree: i.isFree,
isEarlyAccess: i.isEarlyAccess, isComingSoon: i.isComingSoon,
releaseDate: i.releaseDate || null, type: i.type || 'game',
reviewScore: i.reviewScore || null, reviewPercent: i.reviewPercent || null,
capsule: i.capsule || null, tags: (i.tags || []).map(tg => tg.name),
platforms: i.platforms || null,
storeUrl: `https://store.steampowered.com/app/${i.appId}`,
})),
};
downloadBlob(JSON.stringify(data, null, 2), `wishlist_${steamId}_${Date.now()}.json`, 'application/json');
toast(t('exportSuccess') + ` (${items.length} ${t('games')})`, 'success');
}
function downloadBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), CONFIG.BLOB_CLEANUP);
}
// ============================================================
// 面板 UI 构建
// ============================================================
function buildPanel() {
if (document.getElementById('swe-panel')) return;
const fab = document.createElement('button');
fab.id = 'swe-fab';
fab.className = '';
fab.setAttribute('aria-label', t('tabWishlist'));
fab.setAttribute('aria-expanded', 'false');
fab.title = t('tabWishlist');
document.body.appendChild(fab);
fab.addEventListener('click', togglePanel);
const panel = document.createElement('div');
panel.id = 'swe-panel';
panel.className = '';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', t('tabWishlist'));
panel.innerHTML = `
`;
document.body.appendChild(panel);
document.getElementById('swe-load-btn').addEventListener('click', loadAllWishlist);
document.getElementById('swe-export-csv-btn').addEventListener('click', exportCSV);
document.getElementById('swe-export-json-btn').addEventListener('click', exportJSON);
document.getElementById('swe-clear-cache-btn').addEventListener('click', async () => {
if (!confirm(t('clearCacheConfirm'))) return;
await clearCacheDB();
localStorage.removeItem(CACHE_KEYS.FP);
localStorage.removeItem(CACHE_KEYS.META);
localStorage.removeItem(CACHE_KEYS.VERSION);
clearEnhancementCaches(); // v1.1.0:同步清除增强功能缓存
state.items = [];
state.filtered = [];
state.allLoaded = false;
cachedStats = null;
toast(t('clearCache'), 'info');
renderActiveTab();
updateLoadBtn();
});
document.getElementById('swe-settings-btn').addEventListener('click', openSettings);
document.getElementById('swe-close-btn').addEventListener('click', togglePanel);
document.querySelectorAll('.swe-tab').forEach(tab => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
renderActiveTab();
updateLoadBtn();
// 绑定图片错误事件委托(封面 fallback 链处理)
bindImgErrorHandler();
// v2.3.26: 在导航栏"客服"右侧添加"游戏库"菜单入口
addGameLibraryNavEntry();
}
// v2.3.26: 添加游戏库导航菜单入口
function addGameLibraryNavEntry() {
// 如果游戏库展示脚本已创建导航入口,则不重复创建
if (document.querySelector('.sglv-fab')) return;
const navLabel = locale === 'en' ? 'Game Library' : '游戏库';
const entry = document.createElement('a');
entry.className = 'menuitem swe-nav-entry';
entry.href = 'https://store.steampowered.com/';
entry.title = navLabel;
entry.innerHTML = `${ICONS.grid}${navLabel}`;
const supernav = document.querySelector('.supernav_container');
if (supernav) {
const helpLink = supernav.querySelector('a.menuitem[href*="help.steampowered.com"]')
|| Array.from(supernav.querySelectorAll('a.menuitem')).pop();
if (helpLink) helpLink.after(entry);
else supernav.appendChild(entry);
}
}
function togglePanel() {
panelOpen = !panelOpen;
const panel = document.getElementById('swe-panel');
const fab = document.getElementById('swe-fab');
if (!panel || !fab) return;
if (panelOpen) {
panel.classList.add('swe-open');
fab.classList.add('swe-open');
fab.setAttribute('aria-expanded', 'true');
if (!state.allLoaded && state.items.length === 0) {
const cached = loadCachedItems();
cached.then(result => {
if (result.items && result.items.length > 0) {
state.items = result.items;
state.allLoaded = !result.expired;
invalidateStats();
applyFilters();
refreshDynamicFields();
} else {
loadAllWishlist();
}
});
}
} else {
panel.classList.remove('swe-open');
fab.classList.remove('swe-open');
fab.setAttribute('aria-expanded', 'false');
}
}
function switchTab(tab) {
if (activeTab === tab) return;
activeTab = tab;
document.querySelectorAll('.swe-tab').forEach(t => {
const isActive = t.dataset.tab === tab;
t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive);
});
state.page = 1;
renderActiveTab();
}
// ============================================================
// 键盘快捷键
// ============================================================
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panelOpen) {
togglePanel();
return;
}
if (e.ctrlKey || e.metaKey) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
if (e.key === '1' && panelOpen) switchTab('overview');
else if (e.key === '2' && panelOpen) switchTab('wishlist');
else if (e.key === '3' && panelOpen) switchTab('analytics');
else if (e.key === '4' && panelOpen) switchTab('trends');
});
}
// ============================================================
// 初始化
// ============================================================
function detectSteamId() {
const m = window.location.pathname.match(/wishlist\/(?:profiles\/(\d+)|id\/([^/]+))/);
if (m) {
if (m[1]) steamId = m[1];
else if (m[2]) steamId = m[2];
}
return steamId;
}
async function tryAutoLoadCache() {
try {
const cached = await loadCachedItems();
if (cached.items && cached.items.length > 0) {
state.items = cached.items;
state.allLoaded = !cached.expired;
invalidateStats();
applyFilters();
refreshDynamicFields();
if (!cached.expired) {
toast(t('cacheRestored', { count: cached.items.length }), 'info');
} else {
toast(t('cacheExpired'), 'warning');
}
}
} catch (e) {
console.warn('[SWE] auto load cache failed:', e);
}
}
function init() {
if (isInitialized) return;
isInitialized = true;
locale = detectLang();
detectSteamId();
GM_addStyle(CSS);
setTimeout(() => {
// 检测用户货币(Steam 钱包货币 / 国家码 / 头部钱包余额 / SSR 价格 / URL cc / lang)
try {
const detected = detectUserCurrency();
state.currency = detected.cur;
state.currencySource = detected.source;
console.log(`[SWE] currency: ${state.currency.id} (${state.currencySource})`);
} catch (e) { console.warn('[SWE] init currency detect failed:', e); }
// v1.1.4: 初始化时预热类别名缓存(让用户打开面板时直接看到名称,不等 loadAllWishlist)
try { _primeCategoryNamesFromCache(steamId); } catch (e) { /* ignore */ }
// v1.1.9: 同步预热封面 URL 持久化缓存(载入 _sweCoverGood 跨会话数据)
// 后续 getCardImgHtml/getListImgHtml/getCoverImgHtml 可直接命中,避免"特殊游戏反复降级"
try { loadCoverUrlPersisted(); } catch (e) { /* ignore */ }
// v1.1.7+: 同步预取 webapi_token(DOM 已就绪,同步解析并缓存,避免后续每次重复 DOM 扫描)
try { _primeWebApiToken(); } catch (e) { /* ignore */ }
// v1.1.7+: 异步并发预取 webapi_token(store 首页兜底),与构建面板并行
// 不 await:fire-and-forget;token 缓存在 _cachedWebApiToken 即可被后续 getAccessToken 复用
_prefetchWebApiTokenAsync().catch(() => {});
buildPanel();
setupKeyboardShortcuts();
tryAutoLoadCache();
}, CONFIG.INIT_DELAY);
}
// v1.1.4: 从缓存预热类别名(不触发网络,仅 state 填充 + 触发一次 invalidate)
function _primeCategoryNamesFromCache(sid) {
if (!sid) return;
if (Object.keys(state.wishlistCategoryNames).length > 0) return;
const entry = loadCachedCategoryNames(sid);
if (entry && entry.names && Object.keys(entry.names).length > 0) {
state.wishlistCategoryNames = { ...entry.names };
invalidateStats();
console.log(`[SWE] 类别名称已预热 ${Object.keys(entry.names).length} 个 (${entry.hash})`);
}
}
// v1.1.7+: 同步预取 webapi_token(仅解析 DOM,不发网络请求)
function _primeWebApiToken() {
if (_cachedWebApiToken) return _cachedWebApiToken;
const t = getAccessTokenSync();
if (t) {
_cachedWebApiToken = t;
console.log('[SWE] webapi_token 已从 DOM 预热');
}
return _cachedWebApiToken;
}
// v1.1.7+: 异步预取 webapi_token(store 首页兜底,与 buildPanel 并行)
// 设计:
// 1) 与 buildPanel 完全并发,不阻塞主流程
// 2) 与后续 fetchWishlistCategoryNames 并发:API 请求时若 token 已就绪则直接用
// 3) 单次缓存:仅在 _cachedWebApiToken 缺失时启动一次
let _webApiTokenPrefetching = null;
function _prefetchWebApiTokenAsync() {
if (_cachedWebApiToken) return Promise.resolve(_cachedWebApiToken);
if (_webApiTokenPrefetching) return _webApiTokenPrefetching;
_webApiTokenPrefetching = fetchWebApiTokenFromStore().then(tok => {
if (tok) {
_cachedWebApiToken = tok;
console.log('[SWE] webapi_token 已从 store 首页预取');
}
return _cachedWebApiToken;
});
return _webApiTokenPrefetching;
}
// ============================================================
// 启动
// ============================================================
// v1.1.9:页面隐藏/卸载前刷写封面 URL 持久化缓存(防 50ms 节流窗口内数据丢失)
if (typeof window !== 'undefined') {
const _sweFlushOnHide = () => { try { flushCoverUrlPersisted(); } catch (e) { /* ignore */ } };
window.addEventListener('pagehide', _sweFlushOnHide);
window.addEventListener('beforeunload', _sweFlushOnHide);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') _sweFlushOnHide(); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();