// ==UserScript== // @name Steam 愿望单侧边栏 // @name:en Steam Wishlist Sidebar // @namespace https://github.com/steam-card-view // @version 1.3.5 // @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 10L51 21V43L32 54L13 43V21Z' fill='none' stroke='%23fff' stroke-width='2' opacity='0.35'/%3E%3Cpath d='M32 42C32 42 23 35.5 23 28C23 24 26 21.5 29.5 21.5C31 21.5 31.5 22 32 23C32.5 22 33 21.5 34.5 21.5C38 21.5 41 24 41 28C41 35.5 32 42 32 42Z' fill='%23fff'/%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 // @connect steamdb.info // @require https://update.greasyfork.org/scripts/590084/1894626/SGLV%20App%20Detail%20Library.js // @require https://update.greasyfork.org/scripts/590086/1895071/SGLV%20%E6%8B%BC%E9%9F%B3%E5%AD%97%E5%BA%93%20%28Library%29.js // @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="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'; const _F = 'fill="currentColor" stroke="none"'; const _D = 'fill="currentColor" fill-opacity="0.22"'; 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: visible; /* v1.3.0: 改为 visible 以允许搜索下拉浮层溢出 */ gap: 8px; } .swe-title { font-size: 15px; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 9px; min-width: 0; flex: 0 1 auto; /* v1.3.0: 不主动扩展,给搜索框留空间 */ 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; } /* v1.3.1 导出下拉菜单 */ .swe-export-wrap { position: relative; flex-shrink: 0; } .swe-export-pop { position: absolute; top: calc(100% + 6px); right: 0; min-width: 140px; background: var(--swe-bg-solid); border: 1px solid rgba(139, 92, 246, 0.25); border-radius: var(--swe-radius-sm); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 1001; padding: 4px 0; visibility: hidden; opacity: 0; transform: translateY(-6px); transition: visibility 0.16s, opacity 0.16s, transform 0.16s; } .swe-export-pop.show { visibility: visible; opacity: 1; transform: translateY(0); } .swe-export-opt { display: flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 12px; color: var(--swe-text); cursor: pointer; white-space: nowrap; transition: background 0.12s; } .swe-export-opt:hover { background: rgba(139, 92, 246, 0.14); } .swe-export-opt svg { width: 13px; height: 13px; color: var(--swe-text-2); flex-shrink: 0; } @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; } /* v1.3.3 最后更新时间徽章 */ .swe-update-slot { display: inline; } .swe-update-badge { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; color: var(--swe-text-2); white-space: nowrap; } .swe-update-badge svg { width: 10px; height: 10px; opacity: 0.7; 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; } /* ====== v1.3.0 通用游戏搜索 ====== */ .swe-global-search { flex: 1 1 auto; min-width: 120px; max-width: 360px; display: flex; align-items: center; gap: 4px; background: rgba(139, 92, 246, 0.08); border: 1px solid rgba(139, 92, 246, 0.15); border-radius: var(--swe-radius-sm); padding: 3px 8px; position: relative; transition: background 0.16s, border-color 0.16s; } .swe-global-search:hover { background: rgba(139, 92, 246, 0.14); } .swe-global-search:focus-within { background: rgba(139, 92, 246, 0.16); border-color: rgba(139, 92, 246, 0.4); box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.12); } .swe-gs-icon { display: flex; align-items: center; color: var(--swe-text-2); flex-shrink: 0; } .swe-gs-icon svg { width: 13px; height: 13px; } .swe-global-search input { flex: 1; min-width: 0; background: none; border: none; outline: none; color: #fff; font-size: 12px; font-family: inherit; padding: 2px 0; } .swe-global-search input::placeholder { color: #6b7c8c; font-size: 11px; } .swe-gs-clear { display: none; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; background: rgba(255,255,255,0.1); color: var(--swe-text-2); flex-shrink: 0; transition: background 0.16s; } .swe-gs-clear:hover { background: rgba(255,255,255,0.2); color: #fff; } .swe-gs-clear svg { width: 9px; height: 9px; } .swe-global-search.has-text .swe-gs-clear { display: flex; } /* 搜索结果浮层 */ .swe-gs-pop { position: absolute; top: calc(100% + 6px); right: 0; left: auto; min-width: 360px; max-width: 520px; max-height: calc(80vh - 120px); overflow-y: auto; overflow-x: hidden; background: var(--swe-bg-solid); border: 1px solid rgba(139, 92, 246, 0.25); border-radius: var(--swe-radius); box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 1000; padding: 0; visibility: hidden; opacity: 0; transform: translateY(-6px); transition: visibility 0.16s, opacity 0.16s, transform 0.16s; } .swe-gs-pop.show { visibility: visible; opacity: 1; transform: translateY(0); } .swe-gs-pop::-webkit-scrollbar { width: 6px; } .swe-gs-pop::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } .swe-gs-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; font-size: 11px; color: var(--swe-text-2); border-bottom: 1px solid rgba(255,255,255,0.06); position: sticky; top: 0; background: var(--swe-bg-solid); z-index: 1; } .swe-gs-header-count { font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 10px; background: rgba(139, 92, 246, 0.2); color: #c4b5fd; } .swe-gs-item { display: flex; align-items: center; gap: 8px; padding: 6px 12px; cursor: pointer; transition: background 0.12s; } .swe-gs-item:hover, .swe-gs-item.focus { background: rgba(139, 92, 246, 0.12); } .swe-gs-item-thumb { width: 46px; height: 22px; border-radius: 3px; overflow: hidden; background: rgba(255,255,255,0.04); flex-shrink: 0; } .swe-gs-item:hover .swe-gs-item-thumb, .swe-gs-item.focus .swe-gs-item-thumb { transform: scale(1.06); transition: transform 0.16s; } .swe-gs-item-thumb img { width: 100%; height: 100%; object-fit: cover; } .swe-gs-item-body { flex: 1; min-width: 0; } .swe-gs-item-name { font-size: 12px; color: var(--swe-text-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .swe-gs-item.focus .swe-gs-item-name { color: #fff; } .swe-gs-item-name mark { background: rgba(139,92,246,0.3); color: #c4b5fd; border-radius: 2px; padding: 0 1px; } .swe-gs-item-sub { font-size: 10px; color: var(--swe-text-2); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .swe-gs-item-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .swe-gs-item-type { font-size: 9px; font-weight: 600; padding: 1px 6px; border-radius: 3px; text-transform: uppercase; } .swe-gs-item-type.wishlist { background: rgba(245,158,11,0.15); color: #f59e0b; } .swe-gs-item-price { font-size: 10px; color: #4ade80; font-weight: 600; } .swe-gs-item-discount { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(74,222,128,0.15); color: #4ade80; } .swe-gs-empty { padding: 28px 16px; text-align: center; color: var(--swe-text-2); font-size: 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; } .swe-gs-empty-icon { opacity: 0.4; } .swe-gs-empty-icon svg { width: 28px; height: 28px; } .swe-gs-footer { display: flex; gap: 12px; padding: 6px 12px; border-top: 1px solid rgba(255,255,255,0.06); font-size: 10px; color: var(--swe-text-2); } .swe-gs-footer kbd { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); margin: 0 1px; } /* 搜索详情视图(替换内容区) */ .swe-search-detail { padding: 0; flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: rgba(139, 92, 246, 0.3) transparent; } .swe-search-detail::-webkit-scrollbar { width: 6px; } .swe-search-detail::-webkit-scrollbar-track { background: transparent; } .swe-search-detail::-webkit-scrollbar-thumb { background: rgba(139, 92, 246, 0.2); border-radius: 3px; } .swe-search-detail::-webkit-scrollbar-thumb:hover { background: rgba(139, 92, 246, 0.35); } .swe-sd-back { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: rgba(139,92,246,0.06); border-bottom: 1px solid rgba(139,92,246,0.12); cursor: pointer; font-size: 12px; color: var(--swe-text-2); transition: background 0.16s, color 0.16s; position: sticky; top: 0; z-index: 5; } .swe-sd-back:hover { background: rgba(139,92,246,0.14); color: #fff; } .swe-sd-back svg { width: 14px; height: 14px; } .swe-sd-loading { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px 16px; color: var(--swe-text-2); font-size: 12px; } .swe-sd-loading svg { width: 32px; height: 32px; animation: swe-spin 0.9s linear infinite; color: #a78bfa; } .swe-sd-error { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 16px; color: #f87171; font-size: 12px; } .swe-sd-error button { padding: 4px 16px; border-radius: var(--swe-radius-sm); background: rgba(139,92,246,0.15); border: 1px solid rgba(139,92,246,0.3); color: #c4b5fd; cursor: pointer; font-size: 11px; } .swe-sd-hero { width: 100%; max-height: 180px; overflow: hidden; } .swe-sd-hero img { width: 100%; height: auto; object-fit: cover; } .swe-sd-body { padding: 14px 16px; } .swe-sd-name { font-size: 18px; font-weight: 700; color: #fff; margin-bottom: 6px; line-height: 1.3; } .swe-sd-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } .swe-sd-metacritic { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 4px; } .swe-sd-review { font-size: 11px; color: var(--swe-text-2); margin-bottom: 6px; } .swe-sd-desc { font-size: 12px; color: var(--swe-text-2); line-height: 1.6; margin-bottom: 10px; } .swe-sd-meta { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 11px; margin-bottom: 12px; } .swe-sd-meta dt { color: var(--swe-text-2); white-space: nowrap; } .swe-sd-meta dd { color: var(--swe-text-bright); } .swe-sd-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; } .swe-sd-tag { font-size: 10px; padding: 2px 8px; border-radius: 10px; background: rgba(139,92,246,0.12); color: #c4b5fd; } .swe-sd-shots { display: flex; gap: 4px; overflow-x: auto; margin-bottom: 12px; } .swe-sd-shot { width: 160px; height: 90px; border-radius: 4px; overflow: hidden; flex-shrink: 0; background: rgba(255,255,255,0.04); } .swe-sd-shot img { width: 100%; height: 100%; object-fit: cover; } .swe-sd-actions { display: flex; gap: 8px; margin-bottom: 16px; } .swe-sd-btn { padding: 6px 16px; border-radius: var(--swe-radius-sm); font-size: 12px; cursor: pointer; text-decoration: none; display: flex; align-items: center; gap: 4px; transition: background 0.16s; } .swe-sd-btn-primary { background: linear-gradient(135deg,#8b5cf6,#3b82f6); color: #fff; border: none; } .swe-sd-btn-primary:hover { filter: brightness(1.1); } /* 三区块并行加载 */ .swe-sd-section { border-top: 1px solid rgba(139,92,246,0.12); padding: 12px 0; } .swe-sd-section-title { font-size: 13px; font-weight: 600; color: #c4b5fd; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; } .swe-sd-section-title svg { width: 15px; height: 15px; } .swe-sd-section-loading { font-size: 11px; color: var(--swe-text-2); display: flex; align-items: center; gap: 6px; } .swe-sd-section-loading svg { width: 14px; height: 14px; animation: swe-spin 0.9s linear infinite; color: #a78bfa; } .swe-sd-section-error { font-size: 11px; color: #f87171; } /* 多地区价格表 */ .swe-sd-price-table { width: 100%; font-size: 11px; border-collapse: collapse; } .swe-sd-price-table th { text-align: left; padding: 4px 8px; color: var(--swe-text-2); font-weight: 600; border-bottom: 1px solid rgba(255,255,255,0.08); font-size: 10px; } .swe-sd-price-table td { padding: 4px 8px; border-bottom: 1px solid rgba(255,255,255,0.03); color: var(--swe-text-bright); } .swe-sd-price-row-lowest { background: rgba(74,222,128,0.06); } .swe-sd-price-row-lowest td { color: #4ade80; font-weight: 600; } .swe-sd-price-flag { width: 20px; height: 15px; border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; } .swe-sd-price-flag svg { width: 100%; height: 100%; } .swe-sd-price-user-region { font-size: 9px; padding: 1px 5px; border-radius: 3px; background: rgba(139,92,246,0.2); color: #c4b5fd; margin-left: 4px; } .swe-sd-price-discount { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(74,222,128,0.15); color: #4ade80; } .swe-sd-price-failed { font-size: 10px; color: #f87171; padding: 4px 8px; } /* 史低分析区 */ .swe-sd-histlow-card { background: rgba(139,92,246,0.06); border: 1px solid rgba(139,92,246,0.12); border-radius: var(--swe-radius-sm); padding: 10px 12px; } .swe-sd-histlow-row { display: flex; justify-content: space-between; align-items: center; padding: 3px 0; font-size: 11px; } .swe-sd-histlow-label { color: var(--swe-text-2); } .swe-sd-histlow-val { color: var(--swe-text-bright); font-weight: 600; } .swe-sd-histlow-badge { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 4px; display: inline-flex; align-items: center; gap: 3px; } .swe-sd-histlow-badge.at-low { background: rgba(74,222,128,0.15); color: #4ade80; } .swe-sd-histlow-badge.near-low { background: rgba(251,191,36,0.15); color: #fbbf24; } .swe-sd-histlow-badge.above-low { background: rgba(248,113,113,0.15); color: #f87171; } .swe-sd-histlow-badge svg { width: 11px; height: 11px; } /* v1.3.0 详情视图补充样式 */ .swe-sd-wl-badge { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; padding: 2px 8px; border-radius: 10px; background: rgba(245,158,11,0.15); color: #f59e0b; } .swe-sd-wl-badge svg { width: 11px; height: 11px; } .swe-sd-wl-price { font-size: 14px; font-weight: 700; color: #4ade80; } .swe-sd-wl-discount { font-size: 11px; padding: 1px 5px; border-radius: 3px; background: rgba(74,222,128,0.15); color: #4ade80; } .swe-sd-wl-orig { font-size: 11px; color: var(--swe-text-2); text-decoration: line-through; } .swe-gs-footer span { display: inline-flex; align-items: center; gap: 2px; } .swe-sd-btn-secondary { background: rgba(139,92,246,0.15); color: #c4b5fd; border: 1px solid rgba(139,92,246,0.3); } .swe-sd-btn-secondary:hover { background: rgba(139,92,246,0.25); } `; // ============================================================ // 国际化 // ============================================================ const ZH = { total: '总计', games: '游戏', searchPlaceholder: '搜索游戏名称\u2026', filter: '筛选', sortBy: '排序', clearFilter: '清除筛选', exportCSV: 'CSV', exportJSON: 'JSON', exportBtn: '导出', exportJsonOpt: '导出 JSON', exportCsvOpt: '导出 CSV', dataMgmtSection: '数据管理', 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月'], // v1.3.0 通用游戏搜索 gsPlaceholder: '搜索游戏(中文/拼音/英文)', gsHeader: '搜索结果', gsEmpty: '未找到匹配游戏', gsTypeWishlist: '愿望单', gsOpenStore: '打开商店页', gsBack: '返回', gsBackHint: '返回{tab}', dpLoading: '正在获取游戏详情…', dpError: '加载失败', dpRetry: '重试', dpFree: '免费', dpDeveloper: '开发商', dpPublisher: '发行商', dpRelease: '发行日期', dpPlatforms: '平台', dpPrice: '价格', dpStorePage: '商店页面', dpRegionPrices: '多地区比价', dpHistLow: '史低分析', dpLoadingPrices: '正在查询地区价格…', dpLoadingHist: '正在查询史低…', dpUserRegion: '本地区', dpLowest: '最低价', dpNoPrice: '暂无价格', dpPriceFailed: '部分地区查询失败', dpHistLowPrice: '史低价', dpHistLowDate: '史低日期', dpHistLowStore: '史低商店', dpHistLowSource: '数据源', dpHistLowCurr: '当前价', dpHistLowMaxCut: '历史最大折扣', dpHistLowNoData: '暂无史低数据', dpAtLow: '已达史低', dpNearLow: '接近史低', dpAboveLow: '高于史低', dpDistFromLow: '距史低 +{pct}%', dpNoData: '暂无数据', // v1.3.3 最后更新时间 lastUpdate: '最后更新', lastUpdateShort: '更新', }; 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', exportBtn: 'Export', exportJsonOpt: 'Export JSON', exportCsvOpt: 'Export CSV', dataMgmtSection: 'Data Management', 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'], // v1.3.0 global game search gsPlaceholder: 'Search games (CN/Pinyin/EN)', gsHeader: 'Search Results', gsEmpty: 'No matching games', gsTypeWishlist: 'Wishlist', gsOpenStore: 'Open Store', gsBack: 'Back', gsBackHint: 'Back to {tab}', dpLoading: 'Loading game details…', dpError: 'Load failed', dpRetry: 'Retry', dpFree: 'Free', dpDeveloper: 'Developer', dpPublisher: 'Publisher', dpRelease: 'Release Date', dpPlatforms: 'Platforms', dpPrice: 'Price', dpStorePage: 'Store Page', dpRegionPrices: 'Regional Prices', dpHistLow: 'Historical Low', dpLoadingPrices: 'Loading regional prices…', dpLoadingHist: 'Loading historical lows…', dpUserRegion: 'Your Region', dpLowest: 'Lowest', dpNoPrice: 'No price data', dpPriceFailed: 'Some regions failed', dpHistLowPrice: 'Lowest', dpHistLowDate: 'Low Date', dpHistLowStore: 'Low Store', dpHistLowSource: 'Source', dpHistLowCurr: 'Current', dpHistLowMaxCut: 'Max Cut', dpHistLowNoData: 'No historical low data', dpAtLow: 'At Low', dpNearLow: 'Near Low', dpAboveLow: 'Above Low', dpDistFromLow: '+{pct}% above low', dpNoData: 'No data', // v1.3.3 last update time lastUpdate: 'Last Update', lastUpdateShort: 'Update', }; 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 = `
${escHtml(msg)}
`; 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 ` ${dots} `; } // 渲染史低分析内容(扫描结果 + 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 = `
${ICONS.brain} ${t('aiPredictTitle')}${t('aiPredictSub')}
${aiBody}
`; } 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)))); markSearchIndexDirty(); // v1.3.0: 批量移除后重建搜索索引 } 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 = ''; } } }); } // ==================== 游戏最后更新时间(参考 Steam-Wishlist-Lastupdate.js,从 SteamDB patchnotes 获取) ==================== const SWE_UPDATE_KEY = 'swe_update_cache_v1'; const SWE_UPDATE_TTL = 7 * 864e5; // 7 天缓存(游戏更新频率低,减少对 SteamDB 的请求) let _sweUpdateCache = null; const _sweUpdatePending = new Map(); // appid -> Promise,防重复请求 function loadUpdateCache() { if (_sweUpdateCache !== null) return _sweUpdateCache; _sweUpdateCache = {}; try { const raw = GM_getValue(SWE_UPDATE_KEY); if (raw && typeof raw === 'object') _sweUpdateCache = raw; } catch (e) {} return _sweUpdateCache; } function saveUpdateCache() { try { GM_setValue(SWE_UPDATE_KEY, _sweUpdateCache || {}); } catch (e) {} } function clearUpdateCache() { _sweUpdateCache = {}; try { GM_setValue(SWE_UPDATE_KEY, {}); } catch (e) {} } // 格式化更新时间:返回 "{date} ({relative})" 格式 function formatUpdateTime(isoString) { const date = new Date(isoString); if (isNaN(date.getTime())) return ''; const now = new Date(); const diffMs = now - date; if (diffMs < 0) return ''; const diffDays = Math.floor(diffMs / 86400000); const datePart = date.toISOString().slice(0, 10); let relative; if (locale && locale.startsWith('en')) { if (diffDays < 30) relative = `${diffDays}d ago`; else if (diffDays < 365) relative = `${Math.floor(diffDays / 30)}mo ago`; else relative = `${Math.floor(diffDays / 365)}y ago`; } else { if (diffDays < 30) relative = `${diffDays}天前`; else if (diffDays < 365) relative = `${Math.floor(diffDays / 30)}月前`; else relative = `${Math.floor(diffDays / 365)}年前`; } return `${datePart} (${relative})`; } // 从 SteamDB patchnotes 页面解析最新补丁日期 function parseSteamDbPatchDate(htmlText) { try { const doc = new DOMParser().parseFromString(htmlText, 'text/html'); const row = doc.querySelector('#js-builds tr[data-date]'); if (row) { const ts = parseInt(row.getAttribute('data-date'), 10); if (!isNaN(ts)) return new Date(ts * 1000).toISOString(); } // 备用:从 patchnotes 链接中提取日期 const link = doc.querySelector('#js-builds td a[href*="/patchnotes/"]'); const timeTd = doc.querySelector('#js-builds td.tabular-nums'); if (link) { const raw = (link.textContent.trim() + ' ' + (timeTd ? timeTd.textContent.trim() : '')).trim(); if (raw) return raw; } } catch (e) {} return null; } // 获取单个游戏的更新时间(带缓存 + 防重复) function getUpdateInfo(appid) { const id = String(appid); const cache = loadUpdateCache(); const cached = cache[id]; if (cached && cached.iso && Date.now() - (cached.fetchedAt || 0) < SWE_UPDATE_TTL) { return Promise.resolve(cached.iso); } if (_sweUpdatePending.has(id)) return _sweUpdatePending.get(id); const p = new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `https://steamdb.info/app/${id}/patchnotes/`, headers: { 'Accept': 'text/html,application/xhtml+xml' }, timeout: 12000, onload: (resp) => { _sweUpdatePending.delete(id); if (resp.status !== 200 || !resp.responseText) { resolve(null); return; } const iso = parseSteamDbPatchDate(resp.responseText); if (iso) { cache[id] = { iso, fetchedAt: Date.now() }; saveUpdateCache(); } resolve(iso || null); }, onerror: () => { _sweUpdatePending.delete(id); resolve(null); }, ontimeout: () => { _sweUpdatePending.delete(id); resolve(null); }, }); }); _sweUpdatePending.set(id, p); return p; } // 同步获取缓存的更新时间显示文本(无缓存时返回空字符串) function getCachedUpdateDisplay(appid) { const cache = loadUpdateCache(); const cached = cache[String(appid)]; if (cached && cached.iso) return formatUpdateTime(cached.iso); return ''; } // 异步获取更新时间显示文本(有缓存直接返回,无缓存后台获取后返回) function getUpdateDisplay(appid) { const cached = getCachedUpdateDisplay(appid); if (cached) return Promise.resolve(cached); return getUpdateInfo(appid).then(iso => iso ? formatUpdateTime(iso) : ''); } // 为列表中可见的更新时间占位槽异步填充数据 async function enrichUpdateInfoForVisible() { if (activeTab !== 'wishlist') return; const body = document.getElementById('swe-body'); if (!body) return; const slots = body.querySelectorAll('.swe-update-slot'); if (!slots.length) return; const tasks = []; slots.forEach(slot => { if (slot.dataset.filled) return; const id = slot.getAttribute('data-appid'); if (!id) return; tasks.push({ slot, id }); }); if (!tasks.length) return; // 先用缓存数据快速填充 tasks.forEach(tk => { const cached = getCachedUpdateDisplay(tk.id); if (cached) { tk.slot.dataset.filled = '1'; tk.slot.innerHTML = `${ICONS.history}${escHtml(cached)}`; } }); // 未命中缓存的后台获取 const uncached = tasks.filter(tk => !tk.slot.dataset.filled); await runWorkerPool(uncached, 2, async (tk) => { try { const display = await getUpdateDisplay(tk.id); tk.slot.dataset.filled = '1'; tk.slot.innerHTML = display ? `${ICONS.history}${escHtml(display)}` : ''; } catch (e) { tk.slot.dataset.filled = '1'; tk.slot.innerHTML = ''; } }); } // ============================================================ // 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(); // v1.3.3:清除最后更新时间缓存 clearUpdateCache(); 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(); markSearchIndexDirty(); // v1.3.0: 数据重载后重建搜索索引 // 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 `
${gridLines} ${yLabels} ${dots} ${peakLabel} ${xLabels}
`; } // 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 = `${yearParts.join('')}${monthParts}${dayParts.join('')}${cellParts.join('')}`; 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 `
${t('noData')}
`; 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
${topDisc.map((g, i) => ` ${i + 1}
${escapeHTML(g.name)}-${g.discountPct}%
`).join('')}
` : ''; // ── 价格区间分布 ── 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 `
${kpiHtml}
${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 `
${arcs}
${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 `${escapeHTML(name)}`; } // 列表图片 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 `${escapeHTML(name)}`; } // 横板封面图片 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 `${escapeHTML(name)}`; } // 通过 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 `
${ICONS.search}
${filterHtml}
${renderQuickPagination()}
`; } // 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 `
${btns.join('')} ${page}/${totalPages}
`; } // 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 ``; } // v1.1.0:价格历史徽章占位槽(渲染后由 enrichPriceHistoryForVisible 异步填充) function priceHistSlotHtml(item) { return ``; } // v1.3.3:最后更新时间占位槽(渲染后由 enrichUpdateInfoForVisible 异步填充) function updateSlotHtml(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)} ${updateSlotHtml(item)} ${addedHtml}
`; } // 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 ? `
${t('page')} ${page} / ${totalPages} (${items.length} ${t('games')})
` : ''; 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 `
${sidebarHtml}
${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')}
${t('aiModelHelp')}
${t('aiApiUrlHelp')}
${ICONS.trash} ${t('dataMgmtSection')}
${t('clearCacheConfirm')}
`; 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); }); // v1.3.1: 设置页清除缓存按钮 const clearCacheBtn = body.querySelector('#swe-settings-clear-cache'); if (clearCacheBtn) { clearCacheBtn.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(); state.items = []; state.filtered = []; state.allLoaded = false; cachedStats = null; markSearchIndexDirty(); toast(t('clearCache'), 'info'); closeSettings(); renderActiveTab(); updateLoadBtn(); }); clearCacheBtn.addEventListener('mouseenter', () => { clearCacheBtn.style.background = 'rgba(248,113,113,0.16)'; }); clearCacheBtn.addEventListener('mouseleave', () => { clearCacheBtn.style.background = 'rgba(248,113,113,0.08)'; }); } } // ============================================================ // 分析仪表标签页 // ============================================================ function renderAnalytics() { const s = computeStats(); if (s.n === 0) return `
${t('noData')}
`; 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)}
${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 `
${escapeHTML(d.label)} ${d.count}${(d.pct * 100).toFixed(0)}%
${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 `
${arcs}
${total}
${centerLbl}
${legend}
`; } // 分析页专用:评分分布 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 `
${t('noData')}
`; // 根据模式选择数据 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 `
${dateStr} ${relLabel ? `${relLabel}` : ''} ${group.items.length}
${itemsHtml}
`; }).join('')}
` : ''; return ``; } // ============================================================ // v1.3.0 通用游戏搜索(中文/拼音缩写/英文子串匹配) // 设计参考 steam-game-library-viewer 2.9.56,委托 sglv-pinyin.lib.js // 数据源:state.items(愿望单游戏列表),全部标记为 type:'wishlist' // ============================================================ // 实时求值获取拼音库——@require 脚本可能因网络延迟晚于主脚本执行 function _getPinyinLib() { return (typeof window !== 'undefined' && window.SGLVPinyin) || (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVPinyin) || null; } // v1.3.5: 拼音补丁表——sglv-pinyin.lib.js 的 @require 版本仅含 3893 字, // 缺少 90+ 个游戏名常用字(神话/自然/诗意/武侠/颜色等)。 // 由于 lib.table 与内部 _TABLE 是同一引用,运行时 Object.assign 即可生效。 const _PINYIN_SUPPLEMENT = { // 神话/奇幻 '羲':'x','昊':'h','曦':'x','宸':'c','寰':'h','瀛':'y','鸾':'l','麟':'l','麒':'q', '饕':'t','餮':'t','貔':'p','貅':'x','魑':'c','魅':'m','魍':'w','魉':'l','魇':'y', '恺':'k','龠':'y','龑':'y','龘':'d', // 自然/元素 '霖':'l','霏':'f','雯':'w','斐':'f','斓':'l','炜':'w','烨':'y','熠':'y','焱':'y', '煦':'x','熹':'x','玥':'y','珺':'j','瑜':'y','璟':'j','璇':'x','璐':'l','琛':'c', '琦':'q','琪':'q','珑':'l','瀚':'h','潇':'x','霆':'t','淼':'m','漪':'y','滢':'y','潋':'l', // 诗意/文学 '睿':'r','馨':'x','颍':'y','飒':'s','骁':'x','骐':'q','骥':'j','奕':'y','煜':'y', '祺':'q','皓':'h','晟':'s','甯':'n','宥':'y','庾':'y','芷':'z','薇':'w','芊':'q', '茗':'m','荀':'x','茜':'q','菡':'h','萱':'x','蓓':'b','蕙':'h','薰':'x','籁':'l', // 武侠/军事 '弩':'n','铠':'k', // 情感/状态 '槃':'p','瞬':'s', // 颜色/宝石 '瑙':'n','翡':'f','琥':'h','珀':'p','钻':'z','瑾':'j','璞':'p','璧':'b','璨':'c', // 建筑/场所 '榭':'x', // 其他常见游戏名用字 '熙':'x','桀':'j','凰':'h','瑾':'j','琰':'y','瑢':'r','璀':'c','璜':'h','璠':'f', '珩':'h','琉':'l','璃':'l','玎':'d','玢':'b','珞':'l','琛':'c','琦':'q','琬':'w', '琰':'y','瑟':'s','琉':'l','璃':'l', }; let _pinyinSupplemented = false; function _ensurePinyinSupplement() { if (_pinyinSupplemented) return; const lib = _getPinyinLib(); if (!lib || !lib.table) return; let added = 0; for (const ch in _PINYIN_SUPPLEMENT) { if (!(ch in lib.table)) { lib.table[ch] = _PINYIN_SUPPLEMENT[ch]; added++; } } _pinyinSupplemented = true; if (added > 0) console.log(`[SWE] 拼音补丁已注入: +${added} 字 (总 ${Object.keys(lib.table).length})`); } // 标点/空格剥离正则(与 sglv-pinyin.lib.js 一致) const _PUNCT_STRIP_RE = /[\s\-_::·、,,.\/\\()()\[\]【】''""]/g; const _PUNCT_TEST_RE = /[\s\-_::·、,,.\/\\()()\[\]【】''""]/; // 搜索索引 const _searchIndex = []; const _searchIndexByAppid = new Map(); let _searchIndexBuilt = false; function buildSearchIndex() { _searchIndex.length = 0; _searchIndexByAppid.clear(); if (Array.isArray(state.items)) { for (const it of state.items) { if (!it || !it.appId) continue; const appid = Number(it.appId); if (_searchIndexByAppid.has(appid)) continue; _searchIndexByAppid.set(appid, { appid, name: it.name || '', type: 'wishlist', wishlist: true, owned: false, store: false, _item: it, _scoreBias: 20, }); } } // 将 Map 中的条目转入数组——searchByPinyin 读取的是 _searchIndex 数组 for (const entry of _searchIndexByAppid.values()) { _searchIndex.push(entry); } _searchIndexBuilt = true; console.log(`[SWE] 全局搜索索引已构建: ${_searchIndex.length} 条 (wishlist)`); } function markSearchIndexDirty() { _searchIndexBuilt = false; } function searchGames(query, limit = 30) { if (!_searchIndexBuilt) buildSearchIndex(); const lib = _getPinyinLib(); if (!lib) { console.warn('[SWE] 拼音库未加载,搜索不可用'); return []; } _ensurePinyinSupplement(); // v1.3.5: 注入拼音补丁(仅首次执行) if (_searchIndex.length === 0) { console.warn('[SWE] 搜索索引为空,请先加载愿望单数据'); return []; } return lib.searchByPinyin(query, _searchIndex, { limit, getName: e => e.name }); } // 高亮匹配片段 function highlightMatch(name, query) { if (!name || !query) return escHtml(name || ''); const escaped = escHtml(name); const q = String(query).trim(); if (!q) return escaped; const lowerName = name.toLowerCase(); const lowerQ = q.toLowerCase(); const idx = lowerName.indexOf(lowerQ); if (idx >= 0) { return escHtml(name.slice(0, idx)) + '' + escHtml(name.slice(idx, idx + q.length)) + '' + escHtml(name.slice(idx + q.length)); } // 去空格匹配 const nameNoSpace = lowerName.replace(_PUNCT_STRIP_RE, ''); const qNoSpace = lowerQ.replace(_PUNCT_STRIP_RE, ''); const idx2 = nameNoSpace.indexOf(qNoSpace); if (idx2 >= 0) { let count = 0, startOrig = -1, endOrig = -1; for (let i = 0; i < name.length; i++) { const c = name[i].toLowerCase(); if (!_PUNCT_TEST_RE.test(c)) { if (count === idx2) startOrig = i; if (count === idx2 + qNoSpace.length - 1) { endOrig = i + 1; break; } count++; } } if (startOrig >= 0 && endOrig > startOrig) { return escHtml(name.slice(0, startOrig)) + '' + escHtml(name.slice(startOrig, endOrig)) + '' + escHtml(name.slice(endOrig)); } } // 拼音首字母匹配高亮 const lib = _getPinyinLib(); if (lib && typeof lib.toPinyinAbbr === 'function') { const pinyinAbbr = lib.toPinyinAbbr(name).toLowerCase(); const idx3 = pinyinAbbr.indexOf(lowerQ); if (idx3 >= 0) { return escHtml(name.slice(0, idx3)) + '' + escHtml(name.slice(idx3, idx3 + q.length)) + '' + escHtml(name.slice(idx3 + q.length)); } const abbrNoSpace = pinyinAbbr.replace(_PUNCT_STRIP_RE, ''); const idx4 = abbrNoSpace.indexOf(qNoSpace); if (idx4 >= 0) { let count2 = 0, startOrig2 = -1, endOrig2 = -1; for (let i = 0; i < pinyinAbbr.length; i++) { const c = pinyinAbbr[i]; if (!_PUNCT_TEST_RE.test(c)) { if (count2 === idx4) startOrig2 = i; if (count2 === idx4 + qNoSpace.length - 1) { endOrig2 = i + 1; break; } count2++; } } if (startOrig2 >= 0 && endOrig2 > startOrig2) { return escHtml(name.slice(0, startOrig2)) + '' + escHtml(name.slice(startOrig2, endOrig2)) + '' + escHtml(name.slice(endOrig2)); } } } return escaped; } // --- 搜索下拉浮层状态 --- let _gsFocusIdx = -1; let _gsLastResults = []; let _gsLastQuery = ''; let _gsInputEl = null; let _gsPopEl = null; let _gsSearchWrap = null; let _gsClearBtn = null; let _gsDebounceTimer = null; function _gsShowPop(show) { if (!_gsPopEl) return; _gsPopEl.classList.toggle('show', show); } function closeSearchPop() { _gsShowPop(false); _gsFocusIdx = -1; } // 获取游戏缩略图 URL(header 图) function _getGameThumbUrl(appid) { return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/capsule_184x69.jpg`; } function renderSearchResults(query) { if (!_gsPopEl) return; const q = String(query || '').trim(); if (!q) { _gsShowPop(false); _gsLastResults = []; return; } const results = searchGames(q, 30); _gsLastResults = results; _gsLastQuery = q; _gsFocusIdx = -1; if (results.length === 0) { _gsPopEl.innerHTML = `
${ICONS.search}
${escHtml(t('gsEmpty'))}
`; _gsShowPop(true); return; } const header = `
${escHtml(t('gsHeader'))}${results.length}
`; const items = results.map((entry, idx) => { const htmlName = highlightMatch(entry.name, q); const thumbSrc = _getGameThumbUrl(entry.appid); const typeText = t('gsTypeWishlist'); // v1.3.3:从缓存获取更新时间(搜索下拉仅用缓存,不触发后台请求) const cachedUpdate = getCachedUpdateDisplay(entry.appid); let subText = t('gsTypeWishlist') + ' · AppID ' + entry.appid; if (cachedUpdate) subText += ' · ' + t('lastUpdateShort') + ': ' + cachedUpdate; // 价格信息 let priceHtml = ''; const it = entry._item; if (it) { if (it.isFree) { priceHtml = `${escHtml(t('dpFree'))}`; } else if (it.finalPrice > 0) { const cur = state.currency; const sym = cur ? cur.symbol : '¥'; priceHtml = `${sym}${escHtml(String(it.finalPrice))}`; if (it.discountPct > 0) priceHtml += `-${escHtml(String(it.discountPct))}%`; } } return `
${htmlName}
${escHtml(subText)}
${escHtml(typeText)} ${priceHtml}
`; }).join(''); const footer = locale !== 'en' ? `` : ``; _gsPopEl.innerHTML = header + items + footer; _gsShowPop(true); _gsPopEl.querySelectorAll('.swe-gs-item').forEach(itemEl => { const idx = Number(itemEl.dataset.idx); itemEl.addEventListener('mouseenter', () => { _gsFocusIdx = idx; _gsUpdateFocusClass(); }); itemEl.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); const target = _gsLastResults[idx]; if (target) _gsJumpToGame(target); }); }); } function _gsUpdateFocusClass() { if (!_gsPopEl) return; const items = _gsPopEl.querySelectorAll('.swe-gs-item'); items.forEach((el, i) => { el.classList.toggle('focus', i === _gsFocusIdx); }); if (_gsFocusIdx >= 0 && items[_gsFocusIdx]) { items[_gsFocusIdx].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } // --- 搜索详情视图(替换内容区) --- let _sdDetailToken = 0; // 异步竞态保护 let _sdPrevTab = 'overview'; // 返回时的目标 tab let _sdCurrentEntry = null; function closeSearchDetail() { _sdDetailToken++; // 使任何在途的详情请求失效 _sdCurrentEntry = null; activeTab = _sdPrevTab; document.querySelectorAll('.swe-tab').forEach(tb => { const isActive = tb.dataset.tab === _sdPrevTab; tb.classList.toggle('active', isActive); tb.setAttribute('aria-selected', isActive); }); renderActiveTab(); } // 跳转到搜索详情 function _gsJumpToGame(entry) { if (!entry) return; closeSearchPop(); if (_gsInputEl) { _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); } // 记录当前 tab 以便返回 _sdPrevTab = activeTab; _sdCurrentEntry = entry; // 切换到搜索详情视图 activeTab = 'search-detail'; document.querySelectorAll('.swe-tab').forEach(tb => tb.classList.remove('active')); renderActiveTab(); } // 渲染搜索详情视图(在 swe-body 中) function renderSearchDetail() { const entry = _sdCurrentEntry; if (!entry) return ''; const backLabel = t('gsBackHint', { tab: t('tab' + (_sdPrevTab.charAt(0).toUpperCase() + _sdPrevTab.slice(1))) }); return `
${ICONS.chevronLeft} ${escHtml(backLabel)}
${escHtml(t('dpLoading'))}
`; } // 绑定返回按钮 + 启动详情加载 function _bindSearchDetailEvents() { const backBtn = document.getElementById('swe-sd-back'); if (backBtn) backBtn.addEventListener('click', closeSearchDetail); if (_sdCurrentEntry) { _loadAndRenderDetail(_sdCurrentEntry); } } // 加载并渲染详情(3 路并行:详情/地区价格/史低) async function _loadAndRenderDetail(entry) { const token = ++_sdDetailToken; const contentEl = document.getElementById('swe-sd-content'); if (!contentEl) return; // 获取 SGLVAppDetail 库 const A = (typeof unsafeWindow !== 'undefined' && unsafeWindow.SGLVAppDetail) || (typeof window !== 'undefined' && window.SGLVAppDetail); // 渲染基础布局(3 个区块的骨架) contentEl.innerHTML = _renderDetailSkeleton(entry); if (token !== _sdDetailToken) return; // 绑定商店页按钮 const storeBtn = contentEl.querySelector('#swe-sd-store-btn'); if (storeBtn) storeBtn.href = `https://store.steampowered.com/app/${entry.appid}`; // 1) 游戏详情(SGLVAppDetail.loadDetail) _loadDetailSection(entry, A, token, contentEl); // 2) 多地区价格(SGLVAppDetail.loadPrices) _loadPricesSection(entry, A, token, contentEl); // 3) 史低分析(本脚本的 getFullPriceHistory) _loadHistLowSection(entry, token, contentEl); // 4) v1.3.3 最后更新时间(从 SteamDB 获取,静默失败) _loadUpdateSection(entry, token, contentEl); } // 渲染详情骨架 function _renderDetailSkeleton(entry) { const storeUrl = `https://store.steampowered.com/app/${entry.appid}`; const cur = state.currency; const sym = cur ? cur.symbol : '¥'; // 愿望单中的价格信息 let wlPriceHtml = ''; const it = entry._item; if (it) { if (it.isFree) { wlPriceHtml = `${escHtml(t('dpFree'))}`; } else if (it.finalPrice > 0) { wlPriceHtml = `${sym}${escHtml(String(it.finalPrice))}`; if (it.discountPct > 0) { wlPriceHtml += `-${escHtml(String(it.discountPct))}%`; if (it.origPrice) wlPriceHtml += `${sym}${escHtml(String(it.origPrice))}`; } } } return `
${escHtml(entry.name)}
${ICONS.heart} ${escHtml(t('gsTypeWishlist'))} ${wlPriceHtml}
${escHtml(t('dpLoading'))}
${ICONS.dollar} ${escHtml(t('dpRegionPrices'))}
${escHtml(t('dpLoadingPrices'))}
${ICONS.history} ${escHtml(t('dpHistLow'))}
${escHtml(t('dpLoadingHist'))}
${ICONS.history} ${escHtml(t('lastUpdate'))}
${escHtml(t('dpLoading'))}
`; } // 加载游戏详情区块 async function _loadDetailSection(entry, A, token, contentEl) { const sectionEl = contentEl.querySelector('#swe-sd-detail-section'); if (!sectionEl) return; if (!A || typeof A.loadDetail !== 'function') { sectionEl.innerHTML = `
${escHtml(t('dpError'))}
`; return; } try { const d = await A.loadDetail(entry.appid, { useCache: true }); if (token !== _sdDetailToken) return; if (!d) { sectionEl.innerHTML = `
${escHtml(t('dpNoData'))}
`; return; } // 渲染详情 const platforms = []; if (d.platforms) { if (d.platforms.win) platforms.push('Windows'); if (d.platforms.mac) platforms.push('macOS'); if (d.platforms.linux) platforms.push('SteamOS+Linux'); } let metaHtml = ''; if (d.developers && d.developers.length) metaHtml += `
${escHtml(t('dpDeveloper'))}
${escHtml(d.developers.join(', '))}
`; if (d.publishers && d.publishers.length) metaHtml += `
${escHtml(t('dpPublisher'))}
${escHtml(d.publishers.join(', '))}
`; if (d.releaseDate) metaHtml += `
${escHtml(t('dpRelease'))}
${escHtml(d.releaseDate)}
`; if (platforms.length) metaHtml += `
${escHtml(t('dpPlatforms'))}
${escHtml(platforms.join(', '))}
`; let priceHtml = ''; if (d.isFree) { priceHtml = `
${escHtml(t('dpPrice'))}
${escHtml(t('dpFree'))}
`; } else if (d.cnPrice) { priceHtml = `
${escHtml(t('dpPrice'))}
¥${escHtml(String(d.cnPrice.price))}`; if (d.cnPrice.discount > 0) priceHtml += ` -${escHtml(String(d.cnPrice.discount))}%`; priceHtml += `
`; } let reviewHtml = ''; if (d.reviews && d.reviews.summary) { reviewHtml = `
${escHtml(d.reviews.summary)}${d.reviews.count ? ` (${escHtml(d.reviews.count)})` : ''}
`; } let metaBadgeHtml = ''; if (d.metacritic && d.metacritic.score) { const s = d.metacritic.score; const c = s >= 75 ? '#4ade80' : s >= 50 ? '#fbbf24' : '#f87171'; metaBadgeHtml = `Metacritic ${escHtml(String(s))}`; } const genresHtml = (d.genres && d.genres.length) ? `
${d.genres.map(g => `${escHtml(g)}`).join('')}
` : ''; const shotsHtml = (d.screenshots && d.screenshots.length) ? `
${d.screenshots.slice(0, 6).map(s => `
` ).join('')}
` : ''; let heroHtml = ''; if (d.cover) heroHtml = `
`; sectionEl.innerHTML = `${heroHtml} ${metaBadgeHtml} ${reviewHtml} ${d.shortDesc ? `
${escHtml(d.shortDesc)}
` : ''} ${metaHtml || priceHtml ? `
${metaHtml}${priceHtml}
` : ''} ${genresHtml} ${shotsHtml}`; } catch (e) { if (token !== _sdDetailToken) return; sectionEl.innerHTML = `
${escHtml(e.message || String(e))}
`; } } // 加载多地区价格区块 async function _loadPricesSection(entry, A, token, contentEl) { const sectionEl = contentEl.querySelector('#swe-sd-prices-section'); if (!sectionEl) return; if (!A || typeof A.loadPrices !== 'function') { sectionEl.innerHTML = `
${ICONS.dollar} ${escHtml(t('dpRegionPrices'))}
${escHtml(t('dpNoPrice'))}
`; return; } try { const priceData = await A.loadPrices(entry.appid, { useCache: true }); if (token !== _sdDetailToken) return; if (!priceData || !priceData.prices || !priceData.prices.length) { sectionEl.innerHTML = `
${ICONS.dollar} ${escHtml(t('dpRegionPrices'))}
${escHtml(t('dpNoPrice'))}
`; return; } // 找到最低价(CNY 等值最小) let minCny = Infinity; priceData.prices.forEach(p => { if (p.cny != null && p.cny < minCny) minCny = p.cny; }); const rows = priceData.prices.map(p => { const isLowest = p.cny === minCny && minCny !== Infinity; const meta = A.getRegionMeta ? A.getRegionMeta(p.region) : null; const flag = meta ? meta.flag : ''; const name = meta ? meta.name : p.region; const userTag = (priceData.userRegion === p.region) ? `${escHtml(t('dpUserRegion'))}` : ''; const disc = p.discount > 0 ? ` -${escHtml(String(p.discount))}%` : ''; const priceText = p.free ? escHtml(t('dpFree')) : `${escHtml(String(p.price))} ${escHtml(p.currency)}`; return ` ${flag} ${escHtml(name)}${userTag} ${priceText}${disc} ${p.cny != null ? '¥' + escHtml(String(p.cny.toFixed(2))) : '-'} `; }).join(''); let failedHtml = ''; if (priceData.failed && priceData.failed.length) { failedHtml = `
${escHtml(t('dpPriceFailed'))}: ${escHtml(priceData.failed.map(f => f.region).join(', '))}
`; } sectionEl.innerHTML = `
${ICONS.dollar} ${escHtml(t('dpRegionPrices'))}
${rows}
${escHtml(t('dpUserRegion'))}${escHtml(t('dpPrice'))}¥CNY
${failedHtml}`; } catch (e) { if (token !== _sdDetailToken) return; sectionEl.innerHTML = `
${ICONS.dollar} ${escHtml(t('dpRegionPrices'))}
${escHtml(e.message || String(e))}
`; } } // 加载史低分析区块 async function _loadHistLowSection(entry, token, contentEl) { const sectionEl = contentEl.querySelector('#swe-sd-histlow-section'); if (!sectionEl) return; try { const histData = await getFullPriceHistory(String(entry.appid)); if (token !== _sdDetailToken) return; if (!histData || !histData.lowest) { sectionEl.innerHTML = `
${ICONS.history} ${escHtml(t('dpHistLow'))}
${escHtml(t('dpHistLowNoData'))}
`; return; } const low = histData.lowest; const cur = state.currency; const sym = cur ? cur.symbol : '¥'; // 判定状态 const wlItem = entry._item; let statusBadge = ''; if (wlItem && !wlItem.isFree && wlItem.finalPrice > 0) { const ratio = low.price > 0 ? wlItem.finalPrice / low.price : 999; let badgeClass, badgeText, badgeIcon; if (ratio <= 1.0) { badgeClass = 'at-low'; badgeText = t('dpAtLow'); badgeIcon = ICONS.target; } else if (ratio <= 1.1) { badgeClass = 'near-low'; badgeText = t('dpNearLow'); badgeIcon = ICONS.zap; } else { badgeClass = 'above-low'; badgeText = t('dpAboveLow'); badgeIcon = ICONS.percent; } const distPct = ((ratio - 1) * 100).toFixed(0); const distText = ratio <= 1.0 ? '' : ' ' + t('dpDistFromLow', { pct: distPct }); statusBadge = `${badgeIcon} ${escHtml(badgeText)}${escHtml(distText)}`; } const maxCut = histData.discounts && histData.discounts.length ? histData.discounts[0].cut : (low.cut || 0); const rows = [ { label: t('dpHistLowPrice'), val: `${sym}${escHtml(String(low.price))} ${low.currency ? escHtml(low.currency) : ''}` }, { label: t('dpHistLowDate'), val: escHtml(low.date || '-') }, { label: t('dpHistLowStore'), val: escHtml(low.store || '-') }, { label: t('dpHistLowMaxCut'), val: `-${escHtml(String(maxCut))}%` }, { label: t('dpHistLowSource'), val: escHtml(histData.source || '-') }, ]; const rowsHtml = rows.map(r => `
${escHtml(r.label)}${r.val}
`).join(''); sectionEl.innerHTML = `
${ICONS.history} ${escHtml(t('dpHistLow'))}
${statusBadge} ${rowsHtml}
`; } catch (e) { if (token !== _sdDetailToken) return; sectionEl.innerHTML = `
${ICONS.history} ${escHtml(t('dpHistLow'))}
${escHtml(t('dpHistLowNoData'))}
`; } } // v1.3.3:加载最后更新时间区块(从 SteamDB 获取,静默失败) async function _loadUpdateSection(entry, token, contentEl) { const sectionEl = contentEl.querySelector('#swe-sd-update-section'); if (!sectionEl) return; try { const display = await getUpdateDisplay(entry.appid); if (token !== _sdDetailToken) return; if (display) { sectionEl.innerHTML = `
${ICONS.history} ${escHtml(t('lastUpdate'))}
${escHtml(t('lastUpdate'))}${escHtml(display)}
`; } else { // 静默失败:隐藏整个区块 sectionEl.innerHTML = ''; sectionEl.style.display = 'none'; } } catch (e) { if (token !== _sdDetailToken) return; sectionEl.innerHTML = ''; sectionEl.style.display = 'none'; } } // 输入防抖 function _gsDebouncedRender(q) { clearTimeout(_gsDebounceTimer); _gsDebounceTimer = setTimeout(() => renderSearchResults(q), 150); } // 绑定全局搜索框全部事件 function bindGlobalSearchEvents() { _gsInputEl = document.getElementById('swe-global-search-input'); _gsPopEl = document.getElementById('swe-gs-pop'); _gsSearchWrap = document.getElementById('swe-global-search'); _gsClearBtn = document.getElementById('swe-gs-clear'); if (!_gsInputEl || !_gsPopEl) return; // 输入事件 const onInput = () => { const v = _gsInputEl.value; if (v) _gsSearchWrap.classList.add('has-text'); else _gsSearchWrap.classList.remove('has-text'); if (!v.trim()) { _gsShowPop(false); _gsLastResults = []; _gsFocusIdx = -1; return; } _gsDebouncedRender(v); }; _gsInputEl.addEventListener('input', onInput); // 焦点聚焦 → 如有内容则打开下拉 const onFocus = () => { if (_gsInputEl.value.trim() && _gsLastResults.length) _gsShowPop(true); }; _gsInputEl.addEventListener('focus', onFocus); // 键盘事件 const onKeydown = (e) => { const key = e.key; if (key === 'ArrowDown') { e.preventDefault(); if (!_gsLastResults.length) return; _gsShowPop(true); _gsFocusIdx = (_gsFocusIdx + 1) % _gsLastResults.length; _gsUpdateFocusClass(); } else if (key === 'ArrowUp') { e.preventDefault(); if (!_gsLastResults.length) return; _gsShowPop(true); _gsFocusIdx = _gsFocusIdx <= 0 ? _gsLastResults.length - 1 : _gsFocusIdx - 1; _gsUpdateFocusClass(); } else if (key === 'Enter') { e.preventDefault(); if (_gsFocusIdx >= 0 && _gsLastResults[_gsFocusIdx]) { _gsJumpToGame(_gsLastResults[_gsFocusIdx]); } else if (_gsLastResults.length > 0) { _gsJumpToGame(_gsLastResults[0]); } } else if (key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); // 阻止面板 ESC 关闭处理器抢断 if (_gsPopEl.classList.contains('show')) { closeSearchPop(); } else { _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); _gsInputEl.blur(); } } }; _gsInputEl.addEventListener('keydown', onKeydown); // 清除按钮 if (_gsClearBtn) { _gsClearBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); _gsInputEl.value = ''; _gsInputEl.dispatchEvent(new Event('input')); _gsInputEl.focus(); }); } // 点击外部关闭下拉 const onDocClick = (e) => { if (!_gsSearchWrap) return; if (_gsSearchWrap.contains(e.target)) return; closeSearchPop(); }; document.addEventListener('mousedown', onDocClick, true); // 切换 tab 时关闭下拉 document.querySelectorAll('.swe-tab').forEach(tab => { tab.addEventListener('click', () => closeSearchPop()); }); } // ============================================================ // 标签页渲染调度 // ============================================================ 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; case 'search-detail': html = renderSearchDetail(); break; } body.innerHTML = html; // v1.3.0:搜索详情视图渲染后绑定返回按钮 + 启动详情加载 if (activeTab === 'search-detail') { _bindSearchDetailEvents(); return; // 搜索详情不需要执行下方的 tab 事件绑定 } // 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.3.3:异步填充最后更新时间 enrichUpdateInfoForVisible().catch(e => console.warn('[SWE] update info 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 = `
Steam ${t('tabWishlist')} v${_v}
${ICONS.download} ${t('exportJsonOpt')}
${ICONS.barChart} ${t('exportCsvOpt')}
`; document.body.appendChild(panel); document.getElementById('swe-load-btn').addEventListener('click', loadAllWishlist); // v1.3.1: 导出下拉菜单 const exportBtn = document.getElementById('swe-export-btn'); const exportPop = document.getElementById('swe-export-pop'); const exportWrap = document.getElementById('swe-export-wrap'); if (exportBtn && exportPop) { exportBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); exportPop.classList.toggle('show'); }); exportPop.querySelectorAll('.swe-export-opt').forEach(opt => { opt.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); exportPop.classList.remove('show'); if (opt.dataset.action === 'json') exportJSON(); else if (opt.dataset.action === 'csv') exportCSV(); }); }); // 点击外部关闭导出下拉 document.addEventListener('mousedown', (e) => { if (exportWrap && !exportWrap.contains(e.target)) exportPop.classList.remove('show'); }, true); } 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(); // v1.3.0:绑定全局搜索事件 bindGlobalSearchEvents(); // 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(); markSearchIndexDirty(); // v1.3.0: 缓存恢复后重建搜索索引 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) { // v1.3.0: 搜索下拉浮层打开时,ESC 先关闭浮层 if (_gsPopEl && _gsPopEl.classList.contains('show')) { closeSearchPop(); return; } // v1.3.0: 搜索详情视图下,ESC 返回上一个 tab if (activeTab === 'search-detail') { closeSearchDetail(); return; } 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(); markSearchIndexDirty(); // v1.3.0: 缓存恢复后重建搜索索引 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(); } })();