// ==UserScript== // @name Steam 个人日历增强 // @name:en Steam Personal Calendar // @namespace https://github.com/steam-card-view // @version 1.0.4 // @description 打开 Steam 个人日历,右下角把外币标价换成人民币。打折会同时换算原价并划掉,愿望单游戏绿框标出,也可以只看这些。计算器预置卢比、美元、里拉、比索、卢布,本币和金额都能改。即将推出可以只留近 30 天。安装后打开个人日历即可使用。 // @description:en Open Steam’s Personal Calendar and a corner panel converts foreign prices into yuan. Discounts keep a struck-through original, wishlist games get a green outline, and the list can show only those. Presets cover rupees, dollars, lira, pesos, and rubles; the home currency and amount are editable. Upcoming releases can be limited to 30 days. After installing, open the Personal Calendar. // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%238b5cf6%22%2F%3E%3Cstop%20offset%3D%220.55%22%20stop-color%3D%22%233b82f6%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2306b6d4%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22url(%23g)%22%2F%3E%3Crect%20x%3D%2211%22%20y%3D%2216%22%20width%3D%2232%22%20height%3D%2234%22%20rx%3D%226%22%20fill%3D%22%23ffffff%22%2F%3E%3Cpath%20d%3D%22M11%2022h32v4H11z%22%20fill%3D%22%231e1b4b%22%2F%3E%3Cpath%20d%3D%22M11%2022a6%206%200%200%201%206-6h20a6%206%200%200%201%206%206v4H11z%22%20fill%3D%22%231e1b4b%22%2F%3E%3Crect%20x%3D%2219%22%20y%3D%2211%22%20width%3D%223.2%22%20height%3D%229%22%20rx%3D%221.6%22%20fill%3D%22%23ffffff%22%2F%3E%3Crect%20x%3D%2231%22%20y%3D%2211%22%20width%3D%223.2%22%20height%3D%229%22%20rx%3D%221.6%22%20fill%3D%22%23ffffff%22%2F%3E%3Ccircle%20cx%3D%2219%22%20cy%3D%2233%22%20r%3D%222.15%22%20fill%3D%22%238b5cf6%22%2F%3E%3Ccircle%20cx%3D%2227%22%20cy%3D%2233%22%20r%3D%222.15%22%20fill%3D%22%236366f1%22%2F%3E%3Ccircle%20cx%3D%2219%22%20cy%3D%2240%22%20r%3D%222.15%22%20fill%3D%22%233b82f6%22%2F%3E%3Ccircle%20cx%3D%2227%22%20cy%3D%2240%22%20r%3D%222.15%22%20fill%3D%22%2306b6d4%22%2F%3E%3Ccircle%20cx%3D%2219%22%20cy%3D%2247%22%20r%3D%222.15%22%20fill%3D%22%2322d3ee%22%2F%3E%3Ccircle%20cx%3D%2247%22%20cy%3D%2246%22%20r%3D%2213%22%20fill%3D%22%23d97706%22%2F%3E%3Ccircle%20cx%3D%2247%22%20cy%3D%2246%22%20r%3D%2211.2%22%20fill%3D%22%23fbbf24%22%2F%3E%3Cpath%20d%3D%22M41.2%2040.6%20L47%2046.2%20L52.8%2040.6%20M47%2045.4%20V53.4%20M42.4%2048.4%20H51.6%20M42.4%2051.3%20H51.6%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%222.15%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E // @match https://store.steampowered.com/personalcalendar // @match https://store.steampowered.com/personalcalendar/ // @match https://store.steampowered.com/personalcalendar/* // @include /^https:\/\/store\.steampowered\.com\/personalcalendar([/?#].*)?$/ // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @connect open.er-api.com // @connect api.enhancedsteam.com // @connect store.steampowered.com // @run-at document-idle // @license MIT // @noframes // ==/UserScript== (function () { 'use strict'; if (typeof window !== 'undefined' && window.top && window.top !== window.self) return; const VERSION = '1.0.4'; const SETTINGS_KEY = 'spc_settings_v1'; const FX_KEY = 'spc_fx_v1'; const FX_TTL = 60 * 60 * 1000; const DAY = 86400000; const ZERO_DEC = new Set(['JPY', 'KRW', 'VND', 'IDR']); // 识别顺序:长符号在前,避免 $ 吃掉 HK$。¥ 在中文页当人民币,日文页当日元。 const CURRENCIES = [ { id: 'HKD', label: '港币', symbol: 'HK$', tokens: ['HK$'] }, { id: 'TWD', label: '新台币', symbol: 'NT$', tokens: ['NT$'] }, { id: 'ARS', label: '阿根廷比索', symbol: 'ARS$', tokens: ['ARS$'] }, { id: 'CAD', label: '加元', symbol: 'CDN$', tokens: ['CDN$'] }, { id: 'AUD', label: '澳元', symbol: 'A$', tokens: ['A$'] }, { id: 'SGD', label: '新加坡元', symbol: 'S$', tokens: ['S$'] }, { id: 'BRL', label: '巴西雷亚尔', symbol: 'R$', tokens: ['R$'] }, { id: 'MXN', label: '墨西哥比索', symbol: 'Mex$', tokens: ['Mex$'] }, { id: 'INR', label: '印度卢比', symbol: '₹', tokens: ['₹'] }, { id: 'KRW', label: '韩元', symbol: '₩', tokens: ['₩'] }, { id: 'RUB', label: '俄罗斯卢布', symbol: '₽', tokens: ['₽', 'руб'] }, { id: 'TRY', label: '土耳其里拉', symbol: '₺', tokens: ['₺', 'TL'] }, { id: 'VND', label: '越南盾', symbol: '₫', tokens: ['₫'] }, { id: 'IDR', label: '印尼盾', symbol: 'Rp', tokens: ['Rp'] }, { id: 'PHP', label: '菲律宾比索', symbol: '₱', tokens: ['₱'] }, { id: 'THB', label: '泰铢', symbol: '฿', tokens: ['฿'] }, { id: 'MYR', label: '林吉特', symbol: 'RM', tokens: ['RM'] }, { id: 'UAH', label: '格里夫纳', symbol: '₴', tokens: ['₴'] }, { id: 'KZT', label: '坚戈', symbol: '₸', tokens: ['₸'] }, { id: 'EUR', label: '欧元', symbol: '€', tokens: ['€'] }, { id: 'GBP', label: '英镑', symbol: '£', tokens: ['£'] }, { id: 'JPY', label: '日元', symbol: 'JP¥', tokens: ['JP¥', 'JPY'] }, { id: 'CNY', label: '人民币', symbol: '¥', tokens: ['CN¥', 'CNY', '¥'] }, { id: 'USD', label: '美元', symbol: '$', tokens: ['USD', '$'] }, ]; const CURRENCY_MAP = new Map(CURRENCIES.map(item => [item.id, item])); const PRESETS = [ { from: 'INR', to: 'CNY', label: '卢比 → 人民币' }, { from: 'USD', to: 'CNY', label: '美元 → 人民币' }, { from: 'TRY', to: 'CNY', label: '里拉 → 人民币' }, { from: 'ARS', to: 'CNY', label: '比索 → 人民币' }, { from: 'RUB', to: 'CNY', label: '卢布 → 人民币' }, ]; const STR = { zh: { title: '日历换算', collapse: '收起', expand: '展开', summary: '本页 {n} 款 · 愿望单 {w} · 有标价 {p}', summaryEmpty: '还没有读到日历卡片', fxDown: '汇率暂不可用,标价保持原货币', showFx: '显示本币换算', home: '本币', wishOnly: '只看愿望单', wishMark: '标出愿望单', horizon: '即将推出只看近 {n} 天', calc: '价格计算器', amount: '金额', from: '从', to: '到', rate: '1 {from} ≈ {value}', upcomingNote: '即将推出的格子上通常没有标价,换算出现在「过去一个月发行」。', approx: '≈', histLow: '史低', }, en: { title: 'Calendar FX', collapse: 'Hide', expand: 'Show', summary: '{n} on this page · {w} wishlisted · {p} priced', summaryEmpty: 'No calendar cards yet', fxDown: 'Exchange rates unavailable. Prices stay in the store currency.', showFx: 'Show converted prices', home: 'Currency', wishOnly: 'Wishlist only', wishMark: 'Mark wishlist', horizon: 'Upcoming: next {n} days', calc: 'Price calculator', amount: 'Amount', from: 'From', to: 'To', rate: '1 {from} ≈ {value}', upcomingNote: 'Upcoming tiles usually have no price. Conversion shows on last month’s releases.', approx: '≈', histLow: 'Low', }, }; function localePack() { const lang = (document.documentElement.lang || '').toLowerCase(); return lang.startsWith('en') ? STR.en : STR.zh; } function t(key, vars) { const raw = localePack()[key] || STR.zh[key] || key; return String(raw).replace(/\{(\w+)\}/g, (_, name) => (vars && vars[name] != null ? vars[name] : '')); } const DEFAULTS = { v: 1, home: 'CNY', showFx: true, highlightWishlist: true, wishlistOnly: false, horizonOn: false, horizonDays: 30, calcAmount: '1000', calcFrom: 'INR', calcTo: 'CNY', collapsed: false, }; function loadSettings() { let raw = null; try { raw = GM_getValue(SETTINGS_KEY, null); } catch (e) { raw = null; } const saved = raw && typeof raw === 'object' ? raw : {}; return Object.assign({}, DEFAULTS, saved); } function saveSettings(next) { const merged = Object.assign({}, DEFAULTS, next, { v: 1 }); try { GM_setValue(SETTINGS_KEY, merged); } catch (e) { /* ignore */ } return merged; } function gmFetch(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 15000, anonymous: true, responseType: 'json', onload(resp) { if (resp.status !== 200) { reject(new Error('HTTP ' + resp.status)); return; } let data = resp.response; if (data == null && resp.responseText) data = resp.responseText; if (typeof data === 'string') { try { data = JSON.parse(data); } catch (e) { reject(e); return; } } resolve(data); }, onerror: () => reject(new Error('network')), ontimeout: () => reject(new Error('timeout')), }); }); } const fx = { rates: null, loadedAt: 0, error: '' }; function ratesFresh(stamp) { return stamp && (Date.now() - stamp) < FX_TTL; } function readCachedRates() { const keys = [FX_KEY, 'swe_fx_cache']; for (const key of keys) { try { const cached = GM_getValue(key, null); if (cached && cached.rates && ratesFresh(cached.ts)) return cached.rates; } catch (e) { /* ignore */ } } return null; } async function loadRates() { if (fx.rates && ratesFresh(fx.loadedAt)) return fx.rates; const cached = readCachedRates(); if (cached) { fx.rates = cached; fx.loadedAt = Date.now(); fx.error = ''; return fx.rates; } let rates = null; try { const data = await gmFetch('https://api.enhancedsteam.com/v1/supportedcurrencies/'); if (data && data.rates) rates = data.rates; } catch (e) { /* 下一来源 */ } if (!rates) { try { const data = await gmFetch('https://open.er-api.com/v6/latest/USD'); if (data && data.rates) rates = data.rates; } catch (e) { fx.error = e.message || 'fx'; } } if (rates) { fx.rates = rates; fx.loadedAt = Date.now(); fx.error = ''; try { GM_setValue(FX_KEY, { rates, ts: fx.loadedAt, base: 'USD' }); } catch (e) { /* ignore */ } } return fx.rates; } function convertCurrency(amount, fromId, toId, rates) { const value = Number(amount); if (!Number.isFinite(value)) return null; if (!fromId || !toId || fromId === toId) return value; if (!rates) return null; const from = Number(rates[fromId]); const to = Number(rates[toId]); if (!from || !to || from <= 0 || to <= 0) return null; return (value / from) * to; } function currencyById(id) { return CURRENCY_MAP.get(id) || CURRENCY_MAP.get('CNY'); } function formatMoney(amount, id) { const cur = currencyById(id); const digits = ZERO_DEC.has(id) ? 0 : 2; const body = Number(amount).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits, }); return cur.symbol + body; } const MONEY_RE = /(?:NT\$|HK\$|ARS\$|CDN\$|Mex\$|A\$|S\$|R\$|JP¥|CN¥|Rp|RM|TL|₹|₫|₱|฿|₩|₴|₸|€|£|¥|₽|\$)\s*\d[\d,.]*/gi; function tokenCurrency(token) { const text = String(token || ''); const lang = (document.documentElement.lang || '').toLowerCase(); for (const cur of CURRENCIES) { if (cur.id === 'CNY' && lang.startsWith('ja')) continue; if (cur.id === 'JPY' && !lang.startsWith('ja') && text.indexOf('¥') >= 0 && text.indexOf('JP') < 0 && text.indexOf('JPY') < 0) continue; if (cur.tokens.some(item => text.indexOf(item) >= 0)) return cur.id; } return null; } function tokenAmount(token) { const num = String(token).replace(/[^\d.,]/g, '').replace(/,/g, ''); const value = Number(num); return Number.isFinite(value) ? value : null; } function parseMoneyTokens(text) { const out = []; const source = String(text || ''); for (const match of source.matchAll(MONEY_RE)) { const currency = tokenCurrency(match[0]); const amount = tokenAmount(match[0]); if (currency && amount != null) out.push({ currency, amount }); } return out; } function priceSource(widget) { const label = widget.getAttribute('aria-label') || ''; if (label.trim()) return label; const clone = widget.cloneNode(true); clone.querySelectorAll('[data-spc-fx]').forEach(node => node.remove()); return clone.textContent || ''; } function parsePriceWidget(widget) { if (!widget) return null; const source = priceSource(widget); const tokens = parseMoneyTokens(source); if (!tokens.length) return null; const discounted = widget.classList.contains('Discounted') || /%/.test(source); const pctMatch = source.match(/(\d+(?:\.\d+)?)\s*%/); const discountPct = pctMatch ? Number(pctMatch[1]) : 0; if (discounted && tokens.length >= 2) { return { currency: tokens[1].currency || tokens[0].currency, original: tokens[0].amount, final: tokens[1].amount, discountPct, }; } const own = tokens.find(token => token.currency) || tokens[0]; return { currency: own.currency, original: null, final: own.amount, discountPct: 0 }; } function monthIndex(name) { const key = String(name || '').toLowerCase().slice(0, 3); return { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }[key] || 0; } // 整段文本必须就是日期。中文「10月26日」,英文「Oct 26」或「26 Oct」,可带年份。 function parseReleaseLabel(label, now) { const text = String(label || '').trim(); let month = 0; let day = 0; let year = 0; let match = text.match(/^(\d{1,2})月(\d{1,2})日$/); if (match) { month = Number(match[1]); day = Number(match[2]); } else { match = text.match(/^([A-Za-z]+)\.?\s+(\d{1,2})(?:,?\s*(\d{4}))?$/); if (match && monthIndex(match[1])) { month = monthIndex(match[1]); day = Number(match[2]); year = match[3] ? Number(match[3]) : 0; } else { match = text.match(/^(\d{1,2})\s+([A-Za-z]+)\.?(?:,?\s*(\d{4}))?$/); if (match && monthIndex(match[2])) { day = Number(match[1]); month = monthIndex(match[2]); year = match[3] ? Number(match[3]) : 0; } } } if (!month || !day || month > 12 || day > 31) return null; const date = new Date(year || now.getFullYear(), month - 1, day); if (date.getMonth() !== month - 1) return null; date.setHours(0, 0, 0, 0); if (!year && date.getTime() < now.getTime() - 200 * DAY) date.setFullYear(date.getFullYear() + 1); return { label: text, month, day, ts: date.getTime() }; } function startOfDay(date) { const copy = new Date(date.getTime()); copy.setHours(0, 0, 0, 0); return copy.getTime(); } function appIdFromHref(href) { const match = String(href || '').match(/\/app\/(\d+)/); return match ? match[1] : ''; } function sectionFromHref(href) { const text = String(href || ''); if (text.indexOf('__lastmonth') >= 0) return 'lastmonth'; if (text.indexOf('__lastweek') >= 0) return 'lastweek'; if (text.indexOf('__calendar') >= 0) return 'upcoming'; return 'unknown'; } function isPureDate(el, now) { return !!(el && el.nodeType === 1 && el.childElementCount === 0 && parseReleaseLabel(el.textContent, now)); } function dateLabelCount(el, now) { if (!el || !el.querySelectorAll) return 0; let count = 0; el.querySelectorAll('div').forEach(node => { if (isPureDate(node, now)) count += 1; }); return count; } function isDayColumn(el, now) { if (!el || !el.children) return false; let dates = 0; Array.from(el.children).forEach(child => { if (isPureDate(child, now)) dates += 1; }); return dates === 1; } function tileOf(anchor, now) { let node = anchor; for (let i = 0; i < 10 && node && node.parentElement; i++) { if (node !== anchor && isDayColumn(node, now)) return node; const parent = node.parentElement; if (parent === document.body || parent === document.documentElement) return node; const parentDates = dateLabelCount(parent, now); const nodeDates = dateLabelCount(node, now); if (parentDates > 1 && nodeDates <= 1) return node; if (parent.querySelectorAll('a[href*="/app/"]').length > 1 && nodeDates <= 1) return node; node = parent; } return node || anchor; } function releaseNear(anchor, now) { let node = anchor; for (let depth = 0; depth < 8 && node; depth++) { let prev = node.previousElementSibling; while (prev) { if (prev.childElementCount === 0) { const parsed = parseReleaseLabel(prev.textContent, now); if (parsed) return parsed; } prev = prev.previousElementSibling; } node = node.parentElement; } return null; } // 卡片是后续功能的统一入口。区域价、史低写进 extra,不要再改这个形状。 function collectCards(now) { const anchors = document.querySelectorAll('a[href*="/app/"][href*="snr=1_personalcalendar"]'); const cards = []; anchors.forEach(anchor => { if (anchor.closest('[data-spc-root]')) return; const href = anchor.getAttribute('href') || ''; const appId = appIdFromHref(href); if (!appId) return; const section = sectionFromHref(href); const tile = tileOf(anchor, now); const priceRoot = (anchor.querySelector('.StoreSalePriceWidgetContainer') || tile.querySelector('.StoreSalePriceWidgetContainer')); const text = anchor.textContent || ''; const statusAlt = /^(已在愿望单中|在库中|On Wishlist|In Library)$/; const alts = Array.from(anchor.querySelectorAll('img')).map(img => (img.getAttribute('alt') || '').trim()); const name = alts.find(alt => alt && !statusAlt.test(alt)) || ''; cards.push({ appId, name, href, section, release: section === 'upcoming' ? releaseNear(anchor, now) : null, flags: { wishlist: text.indexOf('已在愿望单中') >= 0 || text.indexOf('On Wishlist') >= 0, owned: text.indexOf('在库中') >= 0 || text.indexOf('In Library') >= 0, }, price: parsePriceWidget(priceRoot), anchor, tile, priceRoot, extra: {}, }); }); shareRelease(cards); shareFlags(cards); return cards; } function shareFlags(cards) { const wish = new Set(); const owned = new Set(); cards.forEach(card => { if (card.flags.wishlist) wish.add(card.appId); if (card.flags.owned) owned.add(card.appId); }); cards.forEach(card => { if (wish.has(card.appId)) card.flags.wishlist = true; if (owned.has(card.appId)) card.flags.owned = true; }); } function shareRelease(cards) { const found = new Map(); cards.forEach(card => { if (card.section === 'upcoming' && card.release) found.set(card.appId, card.release); }); cards.forEach(card => { if (card.section === 'upcoming' && !card.release && found.has(card.appId)) card.release = found.get(card.appId); }); } function dayColumns(now) { const columns = []; const nodes = document.querySelectorAll('div'); nodes.forEach(el => { if (el.closest('[data-spc-root]')) return; if (el.childElementCount !== 0) return; const parsed = parseReleaseLabel(el.textContent, now); if (!parsed || !el.parentElement) return; columns.push({ el, column: el.parentElement, release: parsed }); }); return columns; } function setFlag(el, name, on) { if (!el) return; if (on) { if (el.getAttribute(name) !== '1') el.setAttribute(name, '1'); } else if (el.hasAttribute(name)) { el.removeAttribute(name); } } function applyView(cards, settings, now) { const today = startOfDay(now); const horizonEnd = today + Number(settings.horizonDays || 30) * DAY; const hideNodes = new Set(); cards.forEach(card => { if (card.anchor.getAttribute('data-spc-app') !== card.appId) card.anchor.setAttribute('data-spc-app', card.appId); if (card.anchor.getAttribute('data-spc-section') !== card.section) card.anchor.setAttribute('data-spc-section', card.section); setFlag(card.anchor, 'data-spc-wish', !!(settings.highlightWishlist && card.flags.wishlist)); let hide = false; if (settings.wishlistOnly && !card.flags.wishlist) hide = true; if (settings.horizonOn && card.section === 'upcoming' && card.release && card.release.ts > horizonEnd) hide = true; if (hide && card.tile) hideNodes.add(card.tile); }); if (settings.horizonOn) { dayColumns(now).forEach(day => { if (day.release.ts <= horizonEnd) return; const target = dateLabelCount(day.column, now) > 1 ? day.el : day.column; hideNodes.add(target); }); } document.querySelectorAll('[data-spc-hide]').forEach(el => { if (!hideNodes.has(el)) el.removeAttribute('data-spc-hide'); }); hideNodes.forEach(el => setFlag(el, 'data-spc-hide', true)); } function shownPrice(card) { return (card.extra && card.extra.quote) || card.price || null; } function fxLabel(price, home, rates) { if (!price || price.final == null) return null; const final = convertCurrency(price.final, price.currency, home, rates); if (final == null) return null; const approx = t('approx'); let strike = ''; if (price.original != null && price.currency) { const original = convertCurrency(price.original, price.currency, home, rates); if (original != null && Math.abs(original - final) > 0.004) strike = formatMoney(original, home); } return { text: approx + ' ' + formatMoney(final, home), strike }; } function lowLine(card, settings) { const low = card.extra && card.extra.low; if (!low || !(Number(low.amount) > 0)) return ''; const price = shownPrice(card); const cur = low.currency || (price && price.currency) || settings.home; let text = t('histLow') + ' ' + formatMoney(low.amount, cur); if (settings.showFx && fx.rates && cur !== settings.home) { const converted = convertCurrency(low.amount, cur, settings.home, fx.rates); if (converted != null) text += ' ' + t('approx') + ' ' + formatMoney(converted, settings.home); } return text; } function annotationFor(card, settings) { const price = shownPrice(card); const label = settings.showFx && fx.rates ? fxLabel(price, settings.home, fx.rates) : null; const lowText = lowLine(card, settings); if ((!label || !label.text) && !lowText) return null; return { text: (label && label.text) || '', strike: (label && label.strike) || '', lowText }; } function applyFx(cards, settings) { cards.forEach(card => { const host = card.priceRoot || card.tile; if (!host) return; const note = annotationFor(card, settings); let node = Array.from(host.children).find(el => el.hasAttribute && el.hasAttribute('data-spc-fx')) || null; if (!note) { if (node) node.remove(); return; } const sig = note.strike + '|' + note.text + '|' + note.lowText; if (!node) { node = document.createElement('span'); node.className = 'spc-fx'; node.setAttribute('data-spc-fx', '1'); host.appendChild(node); } if (node.getAttribute('data-sig') === sig) return; node.setAttribute('data-sig', sig); node.replaceChildren(); if (note.strike) { const struck = document.createElement('s'); struck.textContent = note.strike; node.appendChild(struck); node.appendChild(document.createTextNode(' ')); } if (note.text) node.appendChild(document.createTextNode(note.text)); if (note.lowText) { const low = document.createElement('span'); low.className = 'spc-low'; low.textContent = note.lowText; node.appendChild(low); } }); } // 步骤按 order 执行。view=20,fx=80。区域价之类插在中间,把结果写进 card.extra.quote / card.extra.low。 const steps = []; function ordered(list) { return list.slice().sort((a, b) => (a.order ?? 100) - (b.order ?? 100)); } function registerStep(step) { if (!step || !step.id) return; const at = steps.findIndex(item => item.id === step.id); if (at >= 0) steps.splice(at, 1, step); else steps.push(step); } registerStep({ id: 'view', order: 20, run: ctx => applyView(ctx.cards, ctx.settings, ctx.now) }); registerStep({ id: 'fx', order: 80, run: ctx => applyFx(ctx.cards, ctx.settings) }); // 面板槽按 order 排列。summary=10,view=20,calc=40。后注册的区块会插进已打开的面板。 const slots = []; function mountSlots(replacedId) { if (!panelRoot) return; const body = panelRoot.querySelector('.spc-body'); if (!body) return; if (replacedId) { const old = body.querySelector(`[data-spc-slot="${replacedId}"]`); if (old) old.remove(); } ordered(slots).forEach(slot => { let section = body.querySelector(`[data-spc-slot="${slot.id}"]`); if (!section) { section = document.createElement('section'); section.setAttribute('data-spc-slot', slot.id); section.innerHTML = slot.mount(); if (slot.bind) slot.bind(section); } body.appendChild(section); }); } function registerSlot(slot) { if (!slot || !slot.id) return; const at = slots.findIndex(item => item.id === slot.id); if (at >= 0) slots.splice(at, 1, slot); else slots.push(slot); mountSlots(at >= 0 ? slot.id : ''); if (panelRoot) { const ctx = { cards: lastCards, settings, now: new Date(), fx, extra: {} }; if (slot.update) slot.update(ctx); } } let settings = loadSettings(); let panelRoot = null; let lastCards = []; function currencyOptions(selected) { return CURRENCIES.map(cur => { const on = cur.id === selected ? ' selected' : ''; return ``; }).join(''); } function summaryText(cards) { const games = new Map(); cards.forEach(card => { const prev = games.get(card.appId) || { wishlist: false, priced: false }; prev.wishlist = prev.wishlist || card.flags.wishlist; prev.priced = prev.priced || !!card.price; games.set(card.appId, prev); }); if (!games.size) return t('summaryEmpty'); let wish = 0; let priced = 0; games.forEach(item => { if (item.wishlist) wish += 1; if (item.priced) priced += 1; }); return t('summary', { n: games.size, w: wish, p: priced }); } function rateText() { if (!fx.rates) return t('fxDown'); const value = convertCurrency(1, settings.calcFrom, settings.calcTo, fx.rates); if (value == null) return t('fxDown'); const digits = value >= 1 ? 4 : 6; const shown = currencyById(settings.calcTo).symbol + value.toFixed(digits); return t('rate', { from: settings.calcFrom, value: shown }); } function calcResult() { const amount = Number(String(settings.calcAmount).replace(/,/g, '')); if (!Number.isFinite(amount)) return '—'; const value = convertCurrency(amount, settings.calcFrom, settings.calcTo, fx.rates); if (value == null) return t('fxDown'); return formatMoney(value, settings.calcTo); } registerSlot({ id: 'summary', order: 10, mount: () => `

${t('upcomingNote')}

`, bind() {}, update(ctx) { const el = panelRoot.querySelector('[data-spc-bind="summary"]'); const next = summaryText(ctx.cards); if (el && el.textContent !== next) el.textContent = next; }, }); registerSlot({ id: 'view', order: 20, mount: () => ` `, bind(root) { root.querySelectorAll('[data-spc-set]').forEach(input => { input.addEventListener('change', () => { const key = input.getAttribute('data-spc-set'); if (input.type === 'checkbox') settings[key] = input.checked; else settings[key] = input.value; if (key === 'home') settings.calcTo = settings.home; settings = saveSettings(settings); syncPanelInputs(); refresh(); }); }); }, update() { syncPanelInputs(); }, }); registerSlot({ id: 'calc', order: 40, mount: () => `
${t('calc')}
${PRESETS.map(item => ``).join('')}
`, bind(root) { root.querySelectorAll('[data-spc-calc]').forEach(input => { const event = input.tagName === 'SELECT' ? 'change' : 'input'; input.addEventListener(event, () => { const field = { amount: 'calcAmount', from: 'calcFrom', to: 'calcTo' }[input.getAttribute('data-spc-calc')]; if (!field) return; settings[field] = input.value; settings = saveSettings(settings); paintCalc(); }); }); root.querySelectorAll('[data-spc-preset]').forEach(btn => { btn.addEventListener('click', () => { const pair = btn.getAttribute('data-spc-preset').split(':'); settings.calcFrom = pair[0]; settings.calcTo = pair[1]; settings = saveSettings(settings); syncPanelInputs(); paintCalc(); }); }); }, update() { paintCalc(); }, }); function syncPanelInputs() { if (!panelRoot) return; panelRoot.querySelectorAll('[data-spc-set]').forEach(input => { const key = input.getAttribute('data-spc-set'); if (input.type === 'checkbox') input.checked = !!settings[key]; else if (input.value !== settings[key]) input.value = settings[key]; }); panelRoot.querySelectorAll('[data-spc-calc]').forEach(input => { const key = input.getAttribute('data-spc-calc'); const value = key === 'amount' ? settings.calcAmount : (key === 'from' ? settings.calcFrom : settings.calcTo); if (document.activeElement !== input && input.value !== String(value)) input.value = value; }); panelRoot.classList.toggle('spc-collapsed', !!settings.collapsed); const toggle = panelRoot.querySelector('[data-spc-toggle]'); const toggleLabel = settings.collapsed ? t('expand') : t('collapse'); if (toggle && toggle.textContent !== toggleLabel) toggle.textContent = toggleLabel; } function paintCalc() { if (!panelRoot) return; const result = panelRoot.querySelector('[data-spc-bind="result"]'); const rate = panelRoot.querySelector('[data-spc-bind="rate"]'); if (result && result.textContent !== calcResult()) result.textContent = calcResult(); const rateLine = rateText(); if (rate && rate.textContent !== rateLine) rate.textContent = rateLine; } function ensurePanel() { const parent = document.documentElement; if (!parent) return; if (!panelRoot) { panelRoot = document.createElement('aside'); panelRoot.className = 'spc-panel'; panelRoot.setAttribute('data-spc-root', '1'); panelRoot.setAttribute('role', 'complementary'); panelRoot.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483000;'; panelRoot.innerHTML = `
${t('title')}
`; panelRoot.querySelector('[data-spc-toggle]').addEventListener('click', () => { settings.collapsed = !settings.collapsed; settings = saveSettings(settings); syncPanelInputs(); }); mountSlots(); } if (!panelRoot.isConnected) parent.appendChild(panelRoot); syncPanelInputs(); } let applying = false; function refresh() { if (!document.documentElement) return; applying = true; try { ensurePanel(); const now = new Date(); lastCards = collectCards(now); const ctx = { cards: lastCards, settings, now, fx, extra: {} }; ordered(steps).forEach(step => step.run(ctx)); ordered(slots).forEach(slot => { if (slot.update) slot.update(ctx); }); } catch (error) { console.warn('[SPC] 刷新失败', error); } finally { applying = false; } } const api = { version: 1, registerStep, registerSlot, getCards: () => lastCards.slice(), getSettings: () => Object.assign({}, settings), setSettings(patch) { settings = saveSettings(Object.assign({}, settings, patch || {})); syncPanelInputs(); if (!applying) refresh(); return Object.assign({}, settings); }, getRates: () => (fx.rates ? Object.assign({}, fx.rates) : null), convert: (amount, fromId, toId) => convertCurrency(amount, fromId, toId, fx.rates), refresh, }; try { const host = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; host.__SPC__ = api; } catch (e) { /* ignore */ } GM_addStyle(` .spc-panel { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; width: 320px; max-width: calc(100vw - 24px); max-height: 72vh; display: flex; flex-direction: column; color: #e2e8f0; background: rgba(15, 19, 42, 0.94); border: 1px solid rgba(139, 92, 246, 0.35); border-radius: 12px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .spc-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(139, 92, 246, 0.2); } .spc-head strong { font-size: 13px; } .spc-textbtn { border: 0; background: transparent; color: #c4b5fd; cursor: pointer; font: inherit; } .spc-body { overflow-x: hidden; overflow-y: auto; padding: 10px 12px 12px; } .spc-collapsed .spc-body { display: none; } .spc-summary { margin: 0 0 6px; font-weight: 650; } .spc-note { margin: 0 0 10px; color: #94a3b8; } .spc-check, .spc-field { display: flex; align-items: center; gap: 6px; margin: 6px 0; min-width: 0; } .spc-field { justify-content: space-between; } .spc-panel input[type="text"], .spc-panel select { box-sizing: border-box; max-width: 100%; border-radius: 6px; padding: 4px 6px; font: inherit; } .spc-panel input[type="text"] { width: 100%; background: #1b1730; color: #f8fafc; border: 1px solid rgba(139, 92, 246, 0.28); } .spc-panel select { field-sizing: fixed; width: 108px; background: #ffffff; color: #111827; border: 1px solid rgba(139, 92, 246, 0.45); color-scheme: light; } .spc-panel select option { background: #ffffff; color: #111827; } .spc-calc-title { margin: 8px 0 6px; font-weight: 700; } .spc-presets { display: flex; flex-wrap: wrap; gap: 6px; } .spc-chip { border: 1px solid rgba(139, 92, 246, 0.35); background: rgba(139, 92, 246, 0.12); color: #ddd6fe; border-radius: 999px; padding: 3px 8px; cursor: pointer; font: inherit; } .spc-calc-row { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px; margin-top: 8px; } .spc-calc-row label { display: flex; flex-direction: column; gap: 4px; min-width: 0; color: #94a3b8; } .spc-calc-row select { width: 100%; } .spc-result { margin-top: 8px; font-size: 18px; font-weight: 750; color: #fff; } .spc-rate { color: #94a3b8; margin-top: 2px; } .spc-fx { display: block; margin-top: 2px; font-size: 11px; line-height: 1.3; color: #c4b5fd; font-weight: 650; } .spc-fx s { color: #94a3b8; font-weight: 500; margin-right: 4px; } .spc-low { display: block; color: #94a3b8; font-weight: 500; } a[data-spc-wish="1"] { box-shadow: inset 0 0 0 2px rgba(74, 222, 128, 0.9); border-radius: 4px; } [data-spc-hide="1"] { display: none !important; } `); let timer = 0; let observedBody = null; function schedule() { if (applying) return; clearTimeout(timer); timer = setTimeout(() => { bindObservers(); refresh(); }, 200); } function mutationIsForeign(mutation) { const target = mutation.target; const el = target && target.nodeType === 1 ? target : (target && target.parentElement); if (!el || !el.closest) return true; return !el.closest('[data-spc-root], [data-spc-fx]'); } const observer = new MutationObserver(mutations => { if (applying) return; if (mutations.some(mutationIsForeign)) schedule(); }); function bindObservers() { const root = document.documentElement; if (!root) return; observer.observe(root, { childList: true }); const body = document.body; if (body && body !== observedBody) { observer.observe(body, { childList: true, subtree: true }); observedBody = body; } } function start() { bindObservers(); refresh(); loadRates().then(refresh).catch(error => { console.warn('[SPC] 汇率加载失败', error); refresh(); }); let tries = 0; const watchdog = setInterval(() => { tries += 1; bindObservers(); const missingPanel = !panelRoot || !panelRoot.isConnected; const waitingCards = !lastCards.length; if (missingPanel || waitingCards) refresh(); if (!missingPanel && !waitingCards) clearInterval(watchdog); else if (tries >= 40) clearInterval(watchdog); }, 500); console.warn('[SPC] v' + VERSION + ' 日历换算已启动,卡片 ' + lastCards.length); } if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true }); })();