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