Greasy Fork

Greasy Fork is available in English.

Wargaming商店货币转换器

Wargaming商店货币转换,悬浮汇率以及折扣显示

当前为 2025-10-14 提交的版本,查看 最新版本

// ==UserScript==
// @name         Wargaming商店货币转换器
// @namespace    http://tampermonkey.net/
// @version      3.6.1
// @description  Wargaming商店货币转换,悬浮汇率以及折扣显示
// @author       SundayRX
// @match        https://wargaming.net/shop/*
// @grant        GM_xmlhttpRequest
// @connect      api.exchangerate-api.com
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        discount: 0.87,
        highlightStyle: `
            .currency-conversion {
                background-color: #ffffcc;
                border-radius: 3px;
                padding: 2px 4px;
                margin-left: 5px;
                font-weight: bold;
                font-size: 0.9em;
                color: #d32f2f;
                display: inline-block;
            }
            .currency-processed {
                display: inline-flex;
                align-items: center;
            }
            #currency-float-panel {
                position: fixed !important;
                left: 20px !important; /* 改为左侧距离 */
                bottom: 20px !important; /* 改为底部距离(左下角核心) */
                /* 移除top和transform,避免垂直居中 */
                background-color: rgba(30, 30, 30, 0.98) !important;
                color: #fff !important;
                padding: 15px !important;
                border-radius: 8px !important;
                box-shadow: 0 4px 15px rgba(0,0,0,0.5) !important;
                z-index: 9999999 !important;
                width: 220px !important;
                font-size: 14px !important;
                line-height: 1.6 !important;
                border: 1px solid #555 !important;
                margin: 0 !important;
                opacity: 1 !important;
                visibility: visible !important;
                pointer-events: auto !important;
                overflow: visible !important;
            }
            #currency-float-panel.hidden {
                opacity: 0 !important;
                visibility: hidden !important;
                pointer-events: none !important;
            }
            #currency-float-panel .panel-title {
                font-weight: bold !important;
                margin-bottom: 8px !important;
                padding-bottom: 5px !important;
                border-bottom: 1px dashed #666 !important;
                color: #fff !important;
                font-size: 15px !important;
            }
            #currency-float-panel .rate-source {
                display: inline-block !important;
                margin-top: 5px !important;
                padding: 2px 6px !important;
                border-radius: 3px !important;
                font-size: 12px !important;
                background-color: #2196F3 !important;
                color: #fff !important;
            }
            #currency-float-panel .rate-source.fallback {
                background-color: #FF9800 !important;
            }
            #currency-float-panel .loading {
                color: #ccc !important;
                font-style: italic !important;
            }
            #currency-float-panel .panel-row {
                display: flex !important;
                justify-content: space-between !important; /* 左右两端对齐 */
                align-items: center !important; /* 垂直居中 */
                margin: 5px 0 !important;
            }
            #currency-float-panel .discount-text {
                color: #4CAF50 !important; /* 绿色突出折扣 */
                font-weight: bold !important;
            }      
        `,
    };

    class Currency {
        constructor(Type, ExchangeRateAPI, ExchangeRateFallBack, MatchRegex, Symbol = null) {
            this.Type = Type;
            this.ExchangeRateAPI = ExchangeRateAPI;
            this.ExchangeRateRemote = null;
            this.ExchangeRateFallBack = this.validateRate(ExchangeRateFallBack, false);
            this.MatchRegex = MatchRegex;
            this.Symbol = Symbol;
            this.requestStatus = 'idle'; // idle/pending/done
            this.pendingElements = [];
            console.log(`[Currency初始化] ${this.Type} 备用汇率: ${this.ExchangeRateFallBack}`);
        }

        validateRate(rate, isRemote = false) {
            if (typeof rate === 'number' && !isNaN(rate) && rate > 0) {
                return rate;
            }
            if (isRemote) {
                console.warn(`[${this.Type}] 远程汇率无效(值: ${rate}),将使用备用汇率`);
                return null;
            } else {
                console.warn(`[${this.Type}] 备用汇率无效(值: ${rate}),兜底为1`);
                return 1;
            }
        }

        fetchExchangeRate() {
            if (this.requestStatus !== 'idle') {
                console.log(`[${this.Type}] 跳过重复请求(当前状态: ${this.requestStatus})`);
                return;
            }
            this.requestStatus = 'pending';
            console.log(`[${this.Type}] 开始请求汇率 API: ${this.ExchangeRateAPI}`);
            updateFloatPanel(this);

            // 超时保护:5秒未响应则强制结束请求
            const timeoutId = setTimeout(() => {
                console.error(`[${this.Type}] 汇率请求超时(5秒)`);
                this.requestStatus = 'done';
                this.triggerPendingElements();
                updateFloatPanel(this);
            }, 5000);

            GM_xmlhttpRequest({
                method: 'GET',
                url: this.ExchangeRateAPI,
                onload: (response) => {
                    clearTimeout(timeoutId); // 清除超时
                    console.log(`[${this.Type}] API响应状态: ${response.status}`);
                    try {

                        // 关键:检查API响应结构是否正确(exchangerate-api的正确结构)
                        if (response.status !== 200) {
                            console.error(`[${this.Type}] API返回失败: ${data.result || '未知错误'}`);
                            this.requestStatus = 'done';
                            this.triggerPendingElements();
                            updateFloatPanel(this);
                            return;
                        }
                        const data = JSON.parse(response.responseText);
                        const remoteRate = this.validateRate(data.rates?.CNY, true);
                        if (remoteRate) {
                            this.ExchangeRateRemote = remoteRate;
                            console.log(`[${this.Type}] 远程汇率有效: ${remoteRate}`);
                        }

                    } catch (e) {
                        console.error(`[${this.Type}] 解析响应失败:`, e);
                    } finally {
                        this.requestStatus = 'done';
                        this.triggerPendingElements();
                        updateFloatPanel(this);
                    }
                },
                onerror: (error) => {
                    clearTimeout(timeoutId); // 清除超时
                    console.error(`[${this.Type}] 请求失败:`, error);
                    this.requestStatus = 'done';
                    this.triggerPendingElements();
                    updateFloatPanel(this);
                }
            });
        }

        getFinalRate() {
            const rate = this.ExchangeRateRemote ?? this.ExchangeRateFallBack;
            console.log(`[${this.Type}] 最终使用汇率: ${rate}`);
            return rate;
        }

        getRateSource() {
            return this.ExchangeRateRemote ? '实时汇率' : '备用汇率';
        }

        addPendingElement(element) {
            if (!this.pendingElements.includes(element) && !element.classList.contains('currency-processed')) {
                this.pendingElements.push(element);
                console.log(`[${this.Type}] 暂存元素(累计: ${this.pendingElements.length})`);
            }
        }

        triggerPendingElements() {
            console.log(`[${this.Type}] 开始处理暂存元素(数量: ${this.pendingElements.length})`);
            this.pendingElements.forEach(element => {
                if (!element.isConnected) {
                    console.log(`[${this.Type}] 元素已被移除,跳过`);
                    return;
                }
                if (element.classList.contains('currency-processed')) {
                    console.log(`[${this.Type}] 元素已处理,跳过`);
                    return;
                }
                ProcessPriceElement(element, this.Type);
            });
            this.pendingElements = [];
        }
    }

    let CurrencyDict = [
        new Currency('ARS', 'https://api.exchangerate-api.com/v4/latest/ARS', 0.005, /([\d,]+(?:\.\d+)?)\s*(ARS)/i),//阿根廷
        new Currency('SGD', 'https://api.exchangerate-api.com/v4/latest/SGD', 5.500, /([\d,]+(?:\.\d+)?)\s*(SGD)/i),//新加坡 土耳其
        new Currency('HKD', 'https://api.exchangerate-api.com/v4/latest/HKD', 0.916, /([\d,]+(?:\.\d+)?)\s*(HKD)/i),//中国香港
        new Currency('TWD', 'https://api.exchangerate-api.com/v4/latest/TWD', 0.233, /([\d,]+(?:\.\d+)?)\s*(TWD)/i),//中国台湾
        new Currency('MOP', 'https://api.exchangerate-api.com/v4/latest/MOP', 0.891, /([\d,]+(?:\.\d+)?)\s*(MOP)/i),//中国澳门
        new Currency('CNY', 'https://api.exchangerate-api.com/v4/latest/CNY', 1.000, null, 'CNY'),//中国大陆        
        new Currency('EUR', 'https://api.exchangerate-api.com/v4/latest/EUR', 8.260, null, 'EUR'),//欧盟地区(德国 俄罗斯)
        new Currency('USD', 'https://api.exchangerate-api.com/v4/latest/USD', 7.200, null, 'USD'),//美国
        new Currency('CAD', 'https://api.exchangerate-api.com/v4/latest/CAD', 5.000, null, 'CAD'),//加拿大
        new Currency('GBP', 'https://api.exchangerate-api.com/v4/latest/GBP', 9.500, null, 'GBP'),//英国
        new Currency('AUD', 'https://api.exchangerate-api.com/v4/latest/AUD', 4.650, null, 'AUD'),//澳洲
        new Currency('JPY', 'https://api.exchangerate-api.com/v4/latest/JPY', 0.050, null, 'JPY'),//日本

    ];

    let currentActiveCurrency = null;
    let isProcessing = false;
    let observer = null;
    let processedElements = new Set();
    let floatPanel = null;


    function createFloatPanel() {
        if (floatPanel) return;

        floatPanel = document.createElement('div');
        floatPanel.id = 'currency-float-panel';
        floatPanel.className = 'hidden';
        floatPanel.innerHTML = `
            <div class="panel-title">WG商店货币转换器(SundayRX)</div>
            <div class="panel-content"><span class="loading">加载中...</span></div>
        `;
        document.body.appendChild(floatPanel);
        console.log('[悬浮窗] 已创建右侧面板');

        // 防移除监听
        const panelObserver = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                mutation.removedNodes.forEach(node => {
                    if (node.id === 'currency-float-panel') {
                        console.warn('[悬浮窗] 被移除,重建中...');
                        floatPanel = null;
                        createFloatPanel();
                        if (currentActiveCurrency) updateFloatPanel(currentActiveCurrency);
                    }
                });
            });
        });
        panelObserver.observe(document.body, { childList: true, subtree: true });

        // 样式保护
        setInterval(() => {
            if (!floatPanel) return;
            floatPanel.style.position = 'fixed';
            floatPanel.style.left = '20px';
            floatPanel.style.bottom = '20px';
            floatPanel.style.zIndex = '9999999';
        }, 300);
    }

    function updateFloatPanel(currency) {
        if (!floatPanel) createFloatPanel();

        // 关键修复1:即使无新元素,只要存在活跃货币,就显示悬浮窗
        if (!currency && currentActiveCurrency) {
            currency = currentActiveCurrency; // 用全局活跃货币覆盖空值
        }

        if (!currency) {
            floatPanel.classList.add('hidden');
            floatPanel.querySelector('.panel-content').innerHTML = '<span class="loading">无目标货币</span>';
            return;
        }

        // 始终显示悬浮窗(只要有活跃货币)
        floatPanel.classList.remove('hidden');
        const contentEl = floatPanel.querySelector('.panel-content');
        const finalRate = currency.getFinalRate();
        const rateSource = currency.getRateSource();
        const sourceClass = rateSource === '实时汇率' ? 'rate-source' : 'rate-source fallback';
        const discountPercent = `${(CONFIG.discount * 100).toFixed(0)}%`;
        if (currency.requestStatus === 'pending') {
            contentEl.innerHTML = `
                <!-- 用 panel-row 类实现 Flex 布局 -->
                <div class="panel-row">
                    <div>商店货币:${currency.Type}</div>
                    <div class="discount-text">折扣:${discountPercent}</div>
                </div>
                <div class="loading">汇率获取中...</div>
            `;
        } else {
            contentEl.innerHTML = `
                <div class="panel-row">
                    <div>当前货币:${currency.Type}</div>
                    <div class="discount-text">折扣:${discountPercent}</div>
                </div>
                <div>
                    <span class="${sourceClass}">${rateSource}</span> 
                    1 ${currency.Type} = ${finalRate.toFixed(4)} CNY
                </div>
            `;
        }
    }
    // ============================================================


    function init() {
        console.log('=== 初始化开始 ===');
        AddStyles();
        createFloatPanel();
        ConvertPageCurrencyValues();
        ObserveDOMChanges();
        console.log('=== 初始化完成 ===');
    }

    function AddStyles() {
        const style = document.createElement('style');
        style.textContent = CONFIG.highlightStyle;
        document.head.insertBefore(style, document.head.firstChild);
        console.log('=== 样式已添加 ===');
    }

    function ExtractCurrencyInfo(element) {
        if (processedElements.has(element) || element.closest('#currency-float-panel')) {
            return [null, null, null];
        }

        const text = element.textContent.trim();
        console.log(`[提取信息] 元素文本: ${text.substring(0, 50)}...`); // 限制长度,避免日志过长

        // 1. 处理Symbol类型(如EUR/USD)
        const currencyCodeEl = element.querySelector('.currency-code');
        if (currencyCodeEl) {
            const title = currencyCodeEl.getAttribute('title')?.trim();
            console.log(`[提取信息] 找到.currency-code,title: ${title}`);
            if (title) {
                const targetCurrency = CurrencyDict.find(c => c.Symbol === title);
                if (targetCurrency) {
                    const priceMatch = text.match(/(\d{1,3}(?:,\d{3})*(?:\.\d+)?)/);
                    console.log(`[提取信息] 价格匹配: ${priceMatch?.[1] || '无'}`);
                    if (priceMatch) {
                        const numericValue = parseFloat(priceMatch[1].replace(/,/g, ''));
                        if (!isNaN(numericValue)) {
                            console.log(`[提取成功] ${title} 数值: ${numericValue}`);
                            currentActiveCurrency = targetCurrency;
                            return [numericValue, title, targetCurrency];
                        }
                    }
                }
            }
        }

        // 2. 处理正则匹配类型(如ARS)
        for (let currency of CurrencyDict) {
            if (!currency.MatchRegex) continue;
            const match = text.match(currency.MatchRegex);
            if (match && match[1]) {
                console.log(`[提取信息] 正则匹配 ${currency.Type}: ${match[1]}`);
                const numericValue = parseFloat(match[1].replace(/,/g, ''));
                if (!isNaN(numericValue)) {
                    console.log(`[提取成功] ${currency.Type} 数值: ${numericValue}`);
                    currentActiveCurrency = currency;
                    return [numericValue, currency.Type, currency];
                }
            }
        }

        console.log(`[提取失败] 元素不含目标货币`);
        return [null, null, null];
    }

    function FindPriceElements() {
        const validElements = [];
        const allPriceEls = document.querySelectorAll('.product-price:not(.currency-processed)');
        console.log(`[查找元素] 找到${allPriceEls.length}个未处理.product-price`);

        allPriceEls.forEach(el => {
            const [_, __, targetCurrency] = ExtractCurrencyInfo(el);
            if (targetCurrency && !processedElements.has(el)) {
                validElements.push(el);
                console.log(`[查找元素] 加入有效元素列表`);
            }
        });

        console.log(`[查找元素] 有效元素总数: ${validElements.length}`);
        // 关键修复2:移除“有效元素为0时重置活跃货币”的逻辑,保留当前活跃货币
        // 原错误代码:if (validElements.length === 0) currentActiveCurrency = null;
        return validElements;
    }

    function FormatCurrency(value, currencyType) {
        const targetCurrency = CurrencyDict.find(c => c.Type === currencyType);
        if (!targetCurrency) {
            console.warn(`[格式化] 未知货币类型: ${currencyType}`);
            return `${value.toFixed(2)} (未知货币)`;
        }

        const finalRate = targetCurrency.getFinalRate();
        const originalCNY = (value * finalRate).toFixed(2);
        const discountedCNY = (originalCNY * CONFIG.discount).toFixed(2);
        console.log(`[格式化] ${value} ${currencyType} → 原始: ${originalCNY} CNY, 折扣后: ${discountedCNY} CNY`);
        return `${originalCNY} (${discountedCNY}) CNY`;
    }

    function ProcessPriceElement(element, currencyType) {
        if (element.classList.contains('currency-processed') || processedElements.has(element)) {
            console.log(`[处理元素] 已处理,跳过`);
            return;
        }

        const [priceValue, _, targetCurrency] = ExtractCurrencyInfo(element);
        if (!priceValue || !targetCurrency) {
            console.log(`[处理元素] 无效价格或货币,跳过`);
            return;
        }

        try {
            const formattedCNY = FormatCurrency(priceValue, currencyType);
            const conversionEl = document.createElement('span');
            conversionEl.className = 'currency-conversion';
            conversionEl.textContent = `≈${formattedCNY}`;
            console.log(`[处理元素] 生成转换标签: ${conversionEl.textContent}`);

            // 兼容多种插入位置,确保能插入
            let insertPoint = element.querySelector('.product-price_wrap')
                || element.querySelector('.price-wrap')
                || element.querySelector('.price')
                || element; // 最后 fallback 到元素自身

            insertPoint.appendChild(conversionEl);
            console.log(`[处理元素] 标签已插入到:`, insertPoint);

            element.classList.add('currency-processed');
            processedElements.add(element);
        } catch (e) {
            console.error(`[处理元素] 插入失败:`, e);
        }
    }

        function ConvertPageCurrencyValues() {
        if (isProcessing) {
            console.log(`[转换流程] 正在处理中,跳过`);
            return;
        }
        isProcessing = true;
        console.log(`=== 开始转换页面货币 ===`);

        if (observer) observer.disconnect();
        const validElements = FindPriceElements();

        // 关键修复3:即使无新元素,只要有活跃货币,就更新悬浮窗(不隐藏)
        if (validElements.length === 0) {
            console.log(`[转换流程] 无新元素,但保留活跃货币`);
            updateFloatPanel(currentActiveCurrency); // 用当前活跃货币更新,而非null
            isProcessing = false;
            if (observer) observer.observe(document.body, { childList: true, subtree: true });
            return;
        }

        // (货币分组处理逻辑保持不变)
        const currencyGroups = {};
        validElements.forEach(el => {
            const [_, currencyType, targetCurrency] = ExtractCurrencyInfo(el);
            if (targetCurrency) {
                if (!currencyGroups[currencyType]) {
                    currencyGroups[currencyType] = { currency: targetCurrency, elements: [] };
                }
                currencyGroups[currencyType].elements.push(el);
            }
        });

        Object.values(currencyGroups).forEach(group => {
            const { currency, elements } = group;
            switch (currency.requestStatus) {
                case 'idle':
                    elements.forEach(el => currency.addPendingElement(el));
                    currency.fetchExchangeRate();
                    break;
                case 'pending':
                    elements.forEach(el => currency.addPendingElement(el));
                    updateFloatPanel(currency);
                    break;
                case 'done':
                    elements.forEach(el => ProcessPriceElement(el, currency.Type));
                    updateFloatPanel(currency);
                    break;
            }
        });

        if (observer) {
            observer.observe(document.body, { childList: true, subtree: true });
        }
        isProcessing = false;
        console.log(`=== 转换流程结束 ===`);
    }

    function ObserveDOMChanges() {
        observer = new MutationObserver(mutations => {
            let hasValidNewElement = false;
            mutations.forEach(mutation => {
                if (mutation.type !== 'childList') return;
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType !== Node.ELEMENT_NODE || node.id === 'currency-float-panel') return;

                    const [_, __, targetCurrency] = ExtractCurrencyInfo(node);
                    const hasChildValidEl = !!node.querySelector('.product-price:not(.currency-processed)')
                        && ExtractCurrencyInfo(node.querySelector('.product-price:not(.currency-processed)'))[2];

                    if (targetCurrency || hasChildValidEl) {
                        hasValidNewElement = true;
                        if (targetCurrency) currentActiveCurrency = targetCurrency;
                        console.log(`[DOM监听] 检测到新增有效元素`);
                    }
                });
            });

            if (hasValidNewElement && !isProcessing) {
                console.log(`[DOM监听] 触发延迟转换`);
                clearTimeout(window.currencyConversionTimeout);
                window.currencyConversionTimeout = setTimeout(ConvertPageCurrencyValues, 500);
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });
        console.log(`[DOM监听] 已开启`);
    }

    // 页面加载完成后初始化
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();