Greasy Fork is available in English.
Wargaming商店外区货币转换,右侧悬浮显示汇率
当前为
// ==UserScript==
// @name Wargaming商店外区货币转换器
// @namespace http://tampermonkey.net/
// @version 3.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;
}
`,
};
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('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('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';
if (currency.requestStatus === 'pending') {
contentEl.innerHTML = `
<div>当前货币:${currency.Type}</div>
<div class="loading">汇率获取中...</div>
`;
} else {
contentEl.innerHTML = `
<div>当前货币:${currency.Type}</div>
<div>转换汇率:1 ${currency.Type} = ${finalRate.toFixed(4)} CNY</div>
<div><span class="${sourceClass}">${rateSource}</span></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();
}
})();