// ==UserScript==
// @name Steam 消费历史分类器
// @namespace http://tampermonkey.net/
// @version 2.1.24
// @description 对Steam消费历史记录进行分类:直购、送礼、退款、内购、充值、买入、卖出;自动识别主货币;新增转区CD查询功能
// @author SmallFork
// @match https://store.steampowered.com/account/history*
// @grant GM_info
// @license MIT
// @tag Steam
// @tag games
// @homepageURL https://keylol.com/t1035599-1-1
// @icon data:image/svg+xml,
// ==/UserScript==
(function() {
'use strict';
// ==================== 配置常量 ====================
const CATEGORIES = [
{ id: 'store', color: '#3b82f6', label: '直购' },
{ id: 'ingame', color: '#06b6d4', label: '内购' },
{ id: 'gift', color: '#ec4899', label: '送礼' },
{ id: 'refund', color: '#f97316', label: '退款' },
{ id: 'convert', color: '#8b5cf6', label: '充值' },
{ id: 'market_buy', color: '#ef4444', label: '买入' },
{ id: 'market_sell', color: '#10b981', label: '卖出' }
];
const ALL_TYPE = { id: 'all', color: '#66c0f4', label: '全部' };
// ===== 数值常量 =====
const CD_DAYS = 90;//转区CD天数
const PAGE_SIZE = 30;//分页每页条数
const MODAL_WIDTH = 900;//弹窗宽度(px)
const DEBOUNCE_MS = 200;//防抖延迟(ms)
const SETTLE_MS = 300;//稳定等待(ms)
const SAFETY_TIMEOUT = 5000;//安全超时(ms)
const TOAST_DURATION = 3000;//Toast显示时长(ms)
const TOAST_FADE_MS = 300;//Toast淡出时长(ms)
// ===== Z-Index =====
const MODAL_Z_INDEX = 10000;//弹窗层级
const DROPDOWN_Z_INDEX = 10001;//下拉菜单层级
// ===== 分类集合 =====
const SPENDING_CATS = new Set(['store', 'gift', 'ingame']);
const SEP_BEFORE = new Set(['convert', 'market_buy']);
const MARKET_CATS = new Set(['market_buy', 'market_sell']);
const CURRENCIES = [
{ id: 'HKD', label: '港币', match: /HK\$\s*[\d,. ]+/, symbol: 'HK$' },
{ id: 'TWD', label: '新台币', match: /NT\$\s*[\d,. ]+/, symbol: 'NT$' },
{ id: 'AUD', label: '澳元', match: /A\$\s*[\d,. ]+/, symbol: 'A$' },
{ id: 'CAD', label: '加元', match: /CDN\$\s*[\d,. ]+/, symbol: 'CDN$' },
{ id: 'NZD', label: '新西兰元', match: /NZ\$\s*[\d,. ]+/, symbol: 'NZ$' },
{ id: 'ARS', label: '阿根廷比索', match: /ARS\$\s*[\d,. ]+/, symbol: 'ARS$', dc: true },
{ id: 'SGD', label: '新加坡元', match: /(? [c.id, c]));
const CUR_BY_SYMBOL = CURRENCIES.reduce((m, c) => { if (!m.has(c.symbol)) m.set(c.symbol, c); return m; }, new Map());
const FLAGS = {
HKD:'',
TWD:'',
AUD:'',
CAD:'',
NZD:'',
ARS:'',
SGD:'',
COL:'',
CLP:'',
MexP:'',
BRL:'',
UYU:'',
USD:'',
CNY:'',
JPY:'',
EUR:'',
GBP:'',
KRW:'',
RUB:'',
TRY:'',
UAH:'',
INR:'',
VND:'',
IDR:'',
PHP:'',
KZT:'',
THB:'',
MYR:'',
CRC:'',
PEN:'',
PLN:'',
NOK:'',
CHF:'',
ILS:'',
SAR:'',
QAR:'',
KWD:'',
AED:''
};
const _S15 = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"';
const PM_ICONS = {
wallet: ``,
alipay: ``,
wechat: ``,
unionpay: ``,
paypal: ``,
mastercard: ``,
visa: ``,
skrill: ``,
other: ``,
};
const pmIconHtml = (id, size = 16) => PM_ICONS[id] ? `${PM_ICONS[id]}` : '';
const PM_METHODS = [
{ id: 'wallet', re: /钱包|錢包|wallet/i, label: '钱包', color: '#f59e0b' },
{ id: 'alipay', re: /支付宝|支付寶|alipay/i, label: '支付宝', color: '#60a5fa' },
{ id: 'wechat', re: /微信|wechat/i, label: '微信', color: '#34d399' },
{ id: 'unionpay', re: /银联|銀聯|unionpay/i, label: '银联', color: '#f472b6' },
{ id: 'paypal', re: /贝宝|貝寶|paypal/i, label: '贝宝', color: '#818cf8' },
{ id: 'mastercard', re: /万事达|萬事達|mastercard/i, label: '万事达', color: '#f97316' },
{ id: 'visa', re: /visa/i, label: 'Visa', color: '#1a1f71' },
{ id: 'skrill', re: /skrill/i, label: 'Skrill', color: '#d946ef' },
{ id: 'other', re: null, label: '其他', color: '#94a3b8' },
];
// ==================== 国际化 ====================
const LANG_MAP = { 'zh-cn': 'zh', 'zh-tw': 'zh-tw', 'zh-hk': 'zh-tw', 'zh-mo': 'zh-tw', 'en': 'en' };
function detectLanguage() {
const lang = (document.documentElement.lang || '').toLowerCase();
if (LANG_MAP[lang]) return LANG_MAP[lang];
return lang.startsWith('zh') && (lang.includes('tw') || lang.includes('hant')) ? 'zh-tw' : (lang.startsWith('zh') ? 'zh' : 'en');
}
const BASE = {
all: '全部', store: '直购', ingame: '内购', gift: '送礼', refund: '退款', convert: '充值', market_buy: '买入', market_sell: '卖出',
searchPlaceholder: '搜索物品名称...', showAll: '显示全部', pagedView: '分页显示', prev: '上一页', next: '下一页',
exportFail: '导出失败', exportNoData: '导出失败: 没有数据', noChartData: '没有消费数据可显示', noData: '暂无数据',
donutTitle: '商店消费分类占比', barTitle: '收支柱状图', regionCdTitle: '转区 CD 查询', giftTitle: '送礼额度计算',
countLabel: '笔数占比', amountLabel: '金额占比', totalLabel: '总计', expendLabel: '商店消费(包含市场)', incomeLabel: '钱包收入',
currencyNote: '金额均以主货币({currency})统计,其他货币不计入。',
giftStore: '商店直购', giftRefund: '退款', giftAllowance: '送礼额度', giftSent: '已送礼(全部)', giftRemaining: '剩余额度', giftOverdraft: '已经超额',
giftStoreDesc: '用户在商店的直接购买金额', giftRefundDesc: '用户申请的退款金额', giftAllowanceDesc: '可用于送礼的总额度', giftSentDesc: '已赠送给其他用户的金额', giftRemainingDesc: '当前可用的送礼余额', giftOverdraftDesc: '已超出可用送礼额度',
giftDistribution: '额度分布', giftAdjustSent: '调整已送礼(30天内)金额', giftSentLegend: '已送礼(30天内)', giftTipText: '以上数据仅供参考,实际金额以系统记录为准。',
csvDate: '日期', csvItem: '物品', csvAction: '操作类型', csvCategory: '分类', csvTotal: '总金额', csvWalletChange: '钱包变更',
csvPm1: '支付方式1', csvPa1: '支付金额1', csvPm2: '支付方式2', csvPa2: '支付金额2', csvPm3: '支付方式3', csvPa3: '支付金额3',
cdCurrentCurrency: '当前货币', cdPrevCurrency: '转区前货币', cdChangeDate: '最近货币变更日', cdExpireDate: '转区CD到期日',
cdFree: 'CD 已结束,可以转区', cdNoChange: '未发生任何转区',
cdDaysUnit: '天', cdElapsedNote: '已过 {elapsed} 天', cdRemainPrefix: 'CD还剩余',
cdHistoryTitle: '转区历史', cdHistoryDate: '日期', cdHistoryFrom: '旧货币', cdHistoryGap: '间隔', cdHistoryTo: '新货币',
cdNote: '转区冷却期为 {days} 天,冷却期间无法再次更改商店地区。点击历史记录可跳转到对应消费记录。',
pageTitle: '{nickname} 的消费历史记录', breadcrumbLicenses: '许可和产品序列号激活', expandToggle: '展开/折叠次要货币统计',
primaryCurrencyHint: '当前主货币:', switchCurrencyHint: '点击切换主货币', subFilterClick: '点击筛选', countUnit: '笔',
btnDonut: '消费分类占比 (D)', btnBar: '收支柱状图 (B)', btnRegionCD: '转区 CD 查询 (R)', btnGift: '送礼额度计算 (G)', btnDiscount: '商店折扣统计 (K)', btnIngame: '内购分析 (I)',
ingameTitle: '内购分析', ingameNoData: '暂无内购记录', ingameItemName: '物品名称', ingameCount: '购买次数', ingameTotalSpent: '总花费', ingameAvgPrice: '均价', ingameNote: '仅统计主货币的内购记录', ingameTotalItems: '物品种类', ingameTotalCount: '购买总次数', ingameTotalSpentAmount: '内购总花费',
discountTitle: '商店折扣统计',
discountCount: '直购', discountAvgOff: '平均折扣', discountSaved: '节省金额汇总', discountStorePaid: '商店直购', discountFullPrice: '原价购买', discountFullPriceTitle: '原价购买明细', discountFullPriceName: '名称', discountFullPricePrice: '价格', discountFullPriceDate: '日期', fpRefunded: '已退款', fpRefundCount: '退款笔数', fpRefundAmt: '退款金额',
discountOff: 'off', discountScaleOriginal: '原价', discountScaleHalf: '半价', discountScaleFree: '免费',
discountBucketTitle: '折扣区间分布',
discountFooter: '折扣统计仅基于当前筛选条件下的交易数据,不包含礼物、退款及非主货币支付的订单。',
exportFormat: '导出{format}格式', analyzeFail: '分析失败: {msg}', exportSuccess: '导出成功',
pmWallet: '钱包', pmAlipay: '支付宝', pmWechat: '微信', pmUnionpay: '银联', pmPaypal: '贝宝', pmMastercard: '万事达', pmVisa: 'Visa', pmSkrill: 'Skrill', pmOther: '其他',
};
const I18N = {
'zh': BASE,
'zh-tw': { ...BASE, store: '直購', ingame: '內購', gift: '送禮', market_buy: '買入', market_sell: '賣出',
searchPlaceholder: '搜尋物品名稱...', showAll: '顯示全部', pagedView: '分頁顯示', prev: '上一頁', next: '下一頁',
exportFail: '匯出失敗', exportNoData: '匯出失敗: 沒有資料', noChartData: '沒有消費資料可顯示', noData: '暫無資料',
donutTitle: '商店消費分類佔比', barTitle: '收支柱狀圖', regionCdTitle: '轉區 CD 查詢', giftTitle: '送禮額度計算',
countLabel: '筆數佔比', amountLabel: '金額佔比', totalLabel: '總計', expendLabel: '商店消費(包含市場)', incomeLabel: '錢包收入',
currencyNote: '金額均以主貨幣({currency})統計,其他貨幣不計入。',
giftStore: '商店直購', giftAllowance: '送禮額度', giftSent: '已送禮(全部)', giftRemaining: '剩餘額度', giftOverdraft: '已經超額',
giftStoreDesc: '用戶在商店的直接購買金額', giftRefundDesc: '用戶申請的退款金額', giftAllowanceDesc: '可用於送禮的總額度', giftSentDesc: '已贈送給其他用戶的金額', giftRemainingDesc: '當前可用的送禮餘額', giftOverdraftDesc: '已超出可用送禮額度',
giftDistribution: '額度分佈', giftAdjustSent: '調整已送禮(30天內)金額', giftSentLegend: '已送禮(30天內)', giftTipText: '以上數據僅供參考,實際金額以系統記錄為準。',
csvAction: '操作類型', csvCategory: '分類', csvTotal: '總金額', csvWalletChange: '錢包變更',
csvPa1: '支付金額1', csvPa2: '支付金額2', csvPa3: '支付金額3',
cdCurrentCurrency: '當前貨幣', cdPrevCurrency: '轉區前貨幣', cdChangeDate: '最近貨幣變更日', cdExpireDate: '轉區CD到期日',
cdFree: 'CD 已結束,可以轉區', cdNoChange: '未發生任何轉區',
cdElapsedNote: '已過 {elapsed} 天', cdRemainPrefix: 'CD還剩餘',
cdHistoryTitle: '轉區歷史', cdHistoryFrom: '舊貨幣', cdHistoryGap: '間隔', cdHistoryTo: '新貨幣',
cdNote: '轉區冷卻期為 {days} 天,冷卻期間無法再次更改商店地區。點擊歷史記錄可跳轉到對應消費記錄。',
pageTitle: '{nickname} 的消費歷史記錄', breadcrumbLicenses: '許可和產品序號啟動', expandToggle: '展開/摺疊次要貨幣統計',
primaryCurrencyHint: '當前主貨幣:', switchCurrencyHint: '點擊切換主貨幣', subFilterClick: '點擊篩選', countUnit: '筆',
btnDonut: '消費分類佔比 (D)', btnBar: '收支柱狀圖 (B)', btnRegionCD: '轉區 CD 查詢 (R)', btnGift: '送禮額度計算 (G)', btnDiscount: '商店折扣統計 (K)', btnIngame: '內購分析 (I)',
ingameTitle: '內購分析', ingameNoData: '暫無內購記錄', ingameItemName: '物品名稱', ingameCount: '購買次數', ingameTotalSpent: '總花費', ingameAvgPrice: '均價', ingameNote: '僅統計主貨幣的內購記錄', ingameTotalItems: '物品種類', ingameTotalCount: '購買總次數', ingameTotalSpentAmount: '內購總花費',
discountTitle: '商店折扣統計',
discountCount: '直購', discountSaved: '節省金額彙總', discountStorePaid: '商店直購', discountFullPrice: '原價購買', discountFullPriceTitle: '原價購買明細', discountFullPriceName: '名稱', discountFullPricePrice: '價格', discountFullPriceDate: '日期', fpRefunded: '已退款', fpRefundCount: '退款筆數', fpRefundAmt: '退款金額',
discountOff: 'off', discountScaleOriginal: '原價', discountScaleHalf: '半價', discountScaleFree: '免費',
discountBucketTitle: '折扣區間分佈',
discountFooter: '折扣統計僅基於當前篩選條件下的交易數據,不包含禮物、退款及非主貨幣支付的訂單。',
exportFormat: '匯出{format}格式', analyzeFail: '分析失敗: {msg}', exportSuccess: '匯出成功',
pmWallet: '錢包', pmAlipay: '支付寶', pmWechat: '微信', pmUnionpay: '銀聯', pmPaypal: '貝寶', pmMastercard: '萬事達', pmVisa: 'Visa', pmOther: '其他',
},
'en': { ...BASE, all: 'All', store: 'Purchase', ingame: 'In-Game', gift: 'Gift', refund: 'Refund', convert: 'Top-up', market_buy: 'Buy', market_sell: 'Sell',
searchPlaceholder: 'Search item name...', showAll: 'Show All', pagedView: 'Paged', prev: 'Prev', next: 'Next',
exportFail: 'Export failed', exportNoData: 'Export failed: No data', noChartData: 'No data to display', noData: 'No data',
donutTitle: 'Spending Breakdown', barTitle: 'Income & Expense', regionCdTitle: 'Region CD Check', giftTitle: 'Gift Allowance',
countLabel: 'Count', amountLabel: 'Amount', totalLabel: 'Total', expendLabel: 'Store Spending (incl. Market)', incomeLabel: 'Wallet Income',
currencyNote: 'Amounts are in primary currency ({currency}) only.',
giftStore: 'Store Purchase', giftRefund: 'Refund', giftAllowance: 'Gift Allowance', giftSent: 'Gifts Sent (All)', giftRemaining: 'Remaining', giftOverdraft: 'Overdraft',
giftStoreDesc: 'Direct store purchase amount', giftRefundDesc: 'Refunded amount', giftAllowanceDesc: 'Total allowance for gifting', giftSentDesc: 'Amount gifted to others', giftRemainingDesc: 'Current available gift balance', giftOverdraftDesc: 'Amount exceeded gift allowance',
giftDistribution: 'Distribution', giftAdjustSent: 'Adjust Gift Sent (30 days)', giftSentLegend: 'Gifts Sent (30 days)', giftTipText: 'Data is for reference only. Actual amounts are subject to system records.',
csvDate: 'Date', csvItem: 'Item', csvAction: 'Action', csvCategory: 'Category', csvTotal: 'Total', csvWalletChange: 'Wallet Change',
csvPm1: 'Payment Method 1', csvPa1: 'Payment Amount 1', csvPm2: 'Payment Method 2', csvPa2: 'Payment Amount 2', csvPm3: 'Payment Method 3', csvPa3: 'Payment Amount 3',
cdCurrentCurrency: 'Current Currency', cdPrevCurrency: 'Previous Currency', cdChangeDate: 'Last Currency Change', cdExpireDate: 'CD Expiry Date',
cdFree: 'CD ended, region change available', cdNoChange: 'No region change detected',
cdDaysUnit: 'days', cdElapsedNote: '{elapsed} days elapsed', cdRemainPrefix: 'CD Remaining',
cdHistoryTitle: 'Region Change History', cdHistoryDate: 'Date', cdHistoryFrom: 'From', cdHistoryGap: 'Gap', cdHistoryTo: 'To',
cdNote: 'Region change cooldown is {days} days. You cannot change your store region during cooldown. Click a history row to jump to the corresponding purchase record.',
pageTitle: "{nickname}'s Purchase History", breadcrumbLicenses: 'Licenses and Product Key Activations',
expandToggle: 'Expand/collapse secondary currency stats', primaryCurrencyHint: 'Primary currency: ', switchCurrencyHint: 'Click to switch', subFilterClick: 'Click to filter', countUnit: '',
btnDonut: 'Spending Breakdown (D)', btnBar: 'Income & Expense (B)', btnRegionCD: 'Region CD Check (R)', btnGift: 'Gift Allowance (G)', btnDiscount: 'Store Discount (K)', btnIngame: 'In-Game Purchase (I)',
ingameTitle: 'In-Game Purchase Analysis', ingameNoData: 'No in-game purchase records', ingameItemName: 'Item Name', ingameCount: 'Purchases', ingameTotalSpent: 'Total Spent', ingameAvgPrice: 'Avg Price', ingameNote: 'Only primary currency in-game purchases are counted', ingameTotalItems: 'Unique Items', ingameTotalCount: 'Total Purchases', ingameTotalSpentAmount: 'Total Spent',
discountTitle: 'Store Discount',
discountCount: 'Purchases', discountAvgOff: 'Avg. Discount', discountSaved: 'Total Saved', discountStorePaid: 'Store Purchase', discountFullPrice: 'Full Price', discountFullPriceTitle: 'Full Price Details', discountFullPriceName: 'Name', discountFullPricePrice: 'Price', discountFullPriceDate: 'Date', fpRefunded: 'Refunded', fpRefundCount: 'Refunds', fpRefundAmt: 'Refund Amount',
discountOff: 'off', discountScaleOriginal: 'Original', discountScaleHalf: 'Half', discountScaleFree: 'Free',
discountBucketTitle: 'Discount Distribution',
discountFooter: 'Discount statistics are based only on filtered transaction data, excluding gifts, refunds, and non-primary currency orders.',
exportFormat: 'Export {format}', analyzeFail: 'Analysis failed: {msg}', exportSuccess: 'Export successful',
pmWallet: 'Wallet', pmAlipay: 'Alipay', pmWechat: 'WeChat', pmUnionpay: 'UnionPay', pmPaypal: 'PayPal', pmMastercard: 'Mastercard', pmOther: 'Other',
},
};
let currentLang = 'zh';
const t = key => I18N[currentLang]?.[key] ?? BASE[key] ?? key;
// ==================== 工具函数 ====================
const escapeHtml = text => String(text ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/\n/g,' ');
const normText = text => (text || '').replace(/\u00a0/g, ' ').trim();
const fmtDate = d => d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` : '';
const MS_PER_DAY = 864e5;
const fmtAmt = v => v < 0 ? `-${primaryCurrency.symbol}${Math.abs(v).toFixed(2)}` : `${primaryCurrency.symbol}${v.toFixed(2)}`;
const fmtAmtHtml = v => { const neg = v < 0; const abs = Math.abs(v).toFixed(2); return `${neg ? '-' : ''}${primaryCurrency.symbol}${abs}`; };
const startOfDay = d => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const curFlagHtml = (id, w = 18, h = 13, mr = 4) => FLAGS[id] ? `${FLAGS[id]}` : '';
const primaryCurHintHtml = () => `${t('primaryCurrencyHint')}${curFlagHtml(primaryCurrency.id)}${primaryCurrency.symbol}${primaryCurrency.id}(${primaryCurrency.label})`;
function parseDateStr(text) {
const normalizedText = normText(text); if (!normalizedText) return null;
const cn = normalizedText.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日?/);
if (cn) return new Date(+cn[1], +cn[2] - 1, +cn[3]);
const p = new Date(normalizedText); return Number.isNaN(p.getTime()) ? null : new Date(p.getFullYear(), p.getMonth(), p.getDate());
}
function showToast(msg, type = 'info', duration = TOAST_DURATION) {
const el = document.createElement('div');
el.className = `shc-toast shc-toast--${type}`; el.textContent = msg;
document.body.appendChild(el);
requestAnimationFrame(() => el.classList.add('shc-toast--visible'));
setTimeout(() => { el.classList.remove('shc-toast--visible'); setTimeout(() => el.remove(), TOAST_FADE_MS); }, duration);
}
const getTBody = () => {
if (state.cachedTBody && !state.cachedTBody.isConnected) state.cachedTBody = null;
return state.cachedTBody || (state.cachedTBody = document.querySelector('table.wallet_history_table tbody'));
};
const waitFor = (sel, ms = 10000) => new Promise((res, rej) => {
const el = document.querySelector(sel); if (el) return res(el);
let done = false;
const obs = new MutationObserver(() => { const e = document.querySelector(sel); if (e) { done = true; obs.disconnect(); clearTimeout(t); res(e); } });
obs.observe(document.body, { childList: true, subtree: true });
const t = setTimeout(() => { if (!done) { obs.disconnect(); rej(new Error('timeout')); } }, ms);
});
const calcTotal = (cat, amt, total) => cat === 'refund' ? total - amt : SPENDING_CATS.has(cat) ? total + amt : total;
const RE = { refund: /退款|Refund/i, convert: /转换|轉換|Convert/i, ingame: /游戏内购买|遊戲內物品購買|In-Game\s*Purchase/i, market: /市场交易|市集交易|Market\s*Transaction/i, gift: /礼物购买|禮物購買|Gift\s*Purchase/i, purchase: /购买|購買|Purchase/i, wallet: /钱包资金|錢包資金|Wallet/i };
function classifyRow(row) {
const text = $typeText(row);
if (!text) return 'other';
if (RE.refund.test(text)) return 'refund';
if (RE.convert.test(text)) return 'convert';
if (RE.ingame.test(text)) return 'ingame';
if (RE.market.test(text)) {
const ct = ($walletChange(row)?.textContent ?? '').trim();
return ct.includes('+') ? 'market_sell' : ct.includes('-') ? 'market_buy' : 'other';
}
if (RE.gift.test(text)) return 'gift';
if (RE.purchase.test(text)) return RE.wallet.test($itemsText(row)) ? 'convert' : 'store';
return 'other';
}
// ==================== 行数据提取辅助 ====================
const $type = row => row.querySelector('td.wht_type');
const $typeText = row => $type(row)?.textContent?.trim() || '';
const $items = row => row.querySelector('td.wht_items');
const $itemsText = row => $items(row)?.textContent ?? '';
const $date = row => row.querySelector('.wht_date');
const $dateText = row => $date(row)?.textContent?.replace(/\s+/g, ' ')?.trim() || '';
const $total = row => row.querySelector('td.wht_total');
const $walletChange = row => row.querySelector('td.wht_wallet_change');
const $basePrice = row => row.querySelector('td.wht_base_price');
const $itemName = row => { const td = $items(row); if (!td) return ''; const divs = td.querySelectorAll('div'); return (divs.length ? divs[0] : td).textContent.trim(); };
const $itemNameFull = row => { const td = $items(row); if (!td) return { game: '', raw: '' }; const divs = td.querySelectorAll('div'); if (divs.length >= 2) return { game: divs[0].textContent.trim(), raw: divs[1].textContent.trim() }; const t = td.textContent.trim(); return { game: t, raw: t }; };
const errorHtml = msg => `
${escapeHtml(t('analyzeFail')).replace('{msg}', escapeHtml(String(msg)))}
`;
// ==================== 数据行与缓存 ====================
function getDataRows() {
if (state.cachedDataRows) return state.cachedDataRows;
const tb = getTBody();
const rows = tb ? Array.from(tb.querySelectorAll('tr.wallet_table_row')).filter(r => r.id !== 'more_history' && !r.querySelector('th') && r.cells.length >= 4) : [];
state.cachedDataRows = rows;
return rows;
}
const invalidateDataRowsCache = () => { state.cachedDataRows = null; };
const DATASET_KEYS = ['category', 'currency', 'payment', 'amount', 'marketCount', 'itemText', 'paymentParts'];
const clearRowCache = row => DATASET_KEYS.forEach(k => delete row.dataset[k]);
// ==================== 货币解析 ====================
const resolveYen = text => /\.\d+/.test(text) ? 'CNY' : 'JPY';
let primaryCurrency = CURRENCY_MAP.get('CNY') || CURRENCIES[CURRENCIES.length - 1];
let skipAutoDetect = false;
let manualCurrency = false; // 用户手动切换过主货币
const _currencyTextCache = new Map();
function detectPrimaryCurrency() {
const rows = getDataRows(); if (!rows.length) return;
for (const row of rows) {
const typeText = $typeText(row);
if (RE.market.test(typeText) || RE.refund.test(typeText)) continue;
const cells = [$total(row), $walletChange(row), row.querySelector('td.wht_wallet_balance')];
const text = cells.map(c => c?.textContent?.trim()).find(t => t && t !== '--') || '';
if (!text) continue;
if (/¥/.test(text) && !/JP¥|JPY/i.test(text)) {
primaryCurrency = CURRENCY_MAP.get(resolveYen(text));
return;
}
for (const cur of CURRENCIES) { if (cur.match.test(text)) { primaryCurrency = cur; return; } }
}
}
function resolveCurrencyId(rawCurrency, amountText) {
const text = amountText || rawCurrency || '';
if (/¥/.test(text) && !/JP¥|JPY/i.test(text)) return null;
if (rawCurrency && CURRENCY_MAP.has(rawCurrency)) return rawCurrency;
for (const cur of CURRENCIES) if (cur.match.test(text)) return cur.id;
if (rawCurrency) for (const cur of CURRENCIES) if (cur.symbol === rawCurrency) return cur.id;
return null;
}
function detectCurrency(row) {
if (row.dataset.currency) return row.dataset.currency;
const cells = [$total(row), row.querySelector('td.wht_price'), $walletChange(row), row.querySelector('td.wht_wallet_balance')];
const texts = cells.map(c => c?.textContent?.trim()).filter(t => t && t !== '--');
const totalText = texts[0] || '';
const allText = texts.join(' ');
const yenText = texts.find(t => /¥/.test(t) && !/JP¥|JPY/i.test(t)) || '';
if (yenText) {
const detected = resolveYen(yenText);
row.dataset.currency = detected; return detected;
}
const cached = _currencyTextCache.get(totalText); if (cached) { row.dataset.currency = cached; return cached; }
for (const cur of CURRENCIES) {
if (cur.id === 'CNY') continue;
if (cur.match.test(allText)) { _currencyTextCache.set(totalText, cur.id); row.dataset.currency = cur.id; return cur.id; }
}
_currencyTextCache.set(totalText, primaryCurrency.id);
row.dataset.currency = primaryCurrency.id; return primaryCurrency.id;
}
// ==================== 支付与金额解析 ====================
const resolvePm = text => { for (const m of PM_METHODS) if (m.re?.test(text)) return m.id; return text ? 'other' : ''; };
function detectPayment(row) {
if (row.dataset.payment !== undefined) return row.dataset.payment;
const payEl = row.querySelector('td.wht_type .wth_payment');
const cur = CURRENCY_MAP.get(row.dataset.currency);
if (payEl) {
const divs = payEl.querySelectorAll('div');
if (divs.length > 1) {
const parts = [...divs].map(div => { const pm = resolvePm(div.textContent.trim()); return pm ? { pm, amt: parseNumber(div.textContent.trim(), cur?.dc) } : null; }).filter(Boolean);
if (parts.length > 1) { row.dataset.payment = 'mixed'; row.dataset.paymentParts = JSON.stringify(parts); return 'mixed'; }
}
const pm = resolvePm(payEl.textContent.trim());
if (pm) { row.dataset.payment = pm; return pm; }
}
row.dataset.payment = ''; return '';
}
function parseNumber(text, decimalComma = false) {
const m = text.match(/[\d]+(?:[ ,.]\d+)*/); if (!m) return 0;
let s = m[0].replace(/ /g, '');
const li = s.lastIndexOf('.'), ci = s.lastIndexOf(',');
if (decimalComma) { s = ci >= 0 ? s.replace(/\./g, '').replace(',', '.') : s.replace(/\./g, ''); }
else if (li > 0 && ci > 0) { s = li > ci ? s.replace(/,/g, '') : s.replace(/\./g, '').replace(',', '.'); }
else { s = s.replace(/,/g, ''); }
return parseFloat(s) || 0;
}
function parseAmount(row) {
if (row.dataset.amount !== undefined) return parseFloat(row.dataset.amount) || 0;
const cat = row.dataset.category;
const cur = CURRENCY_MAP.get(row.dataset.currency);
let val = 0;
if (cat === 'ingame') {
const td = row.querySelector('td.wht_wallet_change');
if (td) val = parseNumber(td.textContent.trim(), cur?.dc);
}
if (!val) {
const td = row.querySelector('td.wht_total'); if (!td) return 0;
val = parseNumber(td.textContent.trim(), cur?.dc);
}
row.dataset.amount = val; return val;
}
// ==================== 昵称功能 ====================
function getNickname() {
return document.querySelector('#account_pulldown')?.textContent.trim()
|| document.querySelector('#global_header .user_persona')?.textContent.trim() || '';
}
function updatePageTitle() {
const nickname = getNickname(), pageHeader = document.querySelector('h2.pageheader');
if (!nickname || !pageHeader) return;
const originalTitle = pageHeader.dataset.originalTitle || pageHeader.textContent.trim();
const newTitle = t('pageTitle').replace('{nickname}', nickname);
let span = pageHeader.querySelector('.page-title-text');
if (!span) {
span = document.createElement('span'); span.className = 'page-title-text';
span.style.cssText = 'max-width:500px;overflow-wrap:break-word';
pageHeader.textContent = ''; pageHeader.appendChild(span);
const clickHandler = e => { if (e.target.closest('.search-box')) return; const show = pageHeader.dataset.showOriginal === 'true'; span.textContent = show ? newTitle : originalTitle; pageHeader.dataset.showOriginal = show ? 'false' : 'true'; };
pageHeader.addEventListener('click', clickHandler); span.setAttribute('data-has-events', 'true');
state.disposers.push(() => pageHeader.removeEventListener('click', clickHandler));
}
span.textContent = newTitle;
pageHeader.dataset.originalTitle = originalTitle; pageHeader.dataset.newTitle = newTitle; pageHeader.dataset.showOriginal = 'false';
document.title = newTitle;
}
// ==================== 状态 ====================
const AMOUNT_KEYS = [...CATEGORIES.map(c => c.id), 'total'];
const makeAmountMap = () => new Map(AMOUNT_KEYS.map(k => [k, 0]));
const resetAmountMap = m => { for (const k of AMOUNT_KEYS) m.set(k, 0); };
const makeViewStats = () => ({ counts: makeAmountMap(), amounts: makeAmountMap(), amtByCur: new Map(), amtByPm: new Map(), cntByCur: new Map(), cntByPm: new Map() });
const state = {
disposers: [], observers: { table: null }, timers: { debounce: null, search: null },
isProcessing: false, cachedTBody: null, cachedDataRows: null,
counts: makeAmountMap(), amounts: makeAmountMap(),
amountsByCurrency: new Map(), amountsByPayment: new Map(),
countsByCurrency: new Map(), countsByPayment: new Map(),
currentFilter: 'all', subFilter: null, searchQuery: '',
currentPage: 1, totalPages: 1, showAllMode: false, showAmounts: true, showSecondaryRow: false,
btnRefs: new Map(), statTotalEl: null, statCountEls: new Map(),
secondaryStats: new Map(), paymentStats: new Map(), secToggleBtn: null,
containerEl: null, searchInputEl: null,
prevBtnEl: null, nextBtnEl: null, showAllBtnEl: null, pageInputEl: null, pageTotalEl: null,
donutRefs: null, barRefs: null, regionCdRefs: null, giftAllowanceRefs: null, discountRefs: null, ingameRefs: null,
};
// ==================== 核心处理 ====================
function applyViewFilters(allRows, vs) {
const tb = getTBody(), visRows = [], needAccum = !!state.searchQuery;
for (const row of allRows) {
row.classList.remove('page-visible');
if (!row.dataset.category) { row.classList.remove('search-match'); continue; }
const matchSearch = !state.searchQuery || (row.dataset.itemText || '').includes(state.searchQuery);
row.classList.toggle('search-match', matchSearch);
if (!matchSearch) continue;
if (state.currentFilter !== 'all' && row.dataset.category !== state.currentFilter) continue;
if (state.subFilter) {
const sf = state.subFilter;
if (sf.category !== 'total' && row.dataset.category !== sf.category) continue;
if (sf.type === 'payment') {
const pm = row.dataset.payment;
if (pm !== sf.key && pm !== 'mixed') continue;
if (pm === 'mixed') { try { if (!JSON.parse(row.dataset.paymentParts || '[]').some(p => p.pm === sf.key)) continue; } catch { continue; } }
if (sf.currencyKey && row.dataset.currency !== sf.currencyKey) continue;
} else if (sf.type === 'currency' && row.dataset.currency !== sf.key) continue;
}
visRows.push(row); if (needAccum) accumulateRow(row, vs);
}
tb?.classList.toggle('search-filtered', state.searchQuery);
refreshStats(visRows, vs);
if (state.showAllMode) {
tb?.classList.toggle('paginated', !!state.subFilter);
tb?.classList.toggle('show-all', !state.subFilter);
for (const r of visRows) r.classList.add('page-visible');
state.totalPages = 1;
} else {
tb?.classList.add('paginated'); tb?.classList.remove('show-all');
state.totalPages = Math.max(1, Math.ceil(visRows.length / PAGE_SIZE));
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
const start = (state.currentPage - 1) * PAGE_SIZE;
for (let i = start; i < Math.min(start + PAGE_SIZE, visRows.length); i++) visRows[i].classList.add('page-visible');
}
updatePagerUI();
}
function processAll() {
if (state.isProcessing) return; state.isProcessing = true;
invalidateDataRowsCache();
try {
const prevId = primaryCurrency.id;
if (!skipAutoDetect && !manualCurrency) detectPrimaryCurrency();
skipAutoDetect = false;
if (primaryCurrency.id !== prevId) {
_currencyTextCache.clear();
for (const row of getDataRows()) clearRowCache(row);
const hintEl = document.querySelector('.shc-primary-currency-hint');
if (hintEl) hintEl.innerHTML = primaryCurHintHtml();
}
resetAmountMap(state.counts); resetAmountMap(state.amounts);
state.amountsByCurrency.clear(); state.amountsByPayment.clear();
state.countsByCurrency.clear(); state.countsByPayment.clear();
const gvs = { counts: state.counts, amounts: state.amounts, amtByCur: state.amountsByCurrency, amtByPm: state.amountsByPayment, cntByCur: state.countsByCurrency, cntByPm: state.countsByPayment };
const allRows = getDataRows();
for (const row of allRows) {
if (!row.dataset.category) {
row.dataset.category = classifyRow(row);
row.dataset.itemText = $itemsText(row).toLowerCase();
row.dataset.currency = detectCurrency(row);
row.dataset.payment = detectPayment(row);
row.dataset.amount = parseAmount(row);
const cat = row.dataset.category;
row.dataset.marketCount = MARKET_CATS.has(cat) ? (parseInt($type(row)?.querySelector('div')?.textContent, 10) || 1) : 1;
// 从内购行超链接中提取 appid
const onclickAttr = row.getAttribute('onclick') || '';
const appidMatch = onclickAttr.match(/appid=(\d+)/);
if (appidMatch) row.dataset.appid = appidMatch[1];
}
accumulateRow(row, gvs);
}
applyViewFilters(allRows, makeViewStats());
} catch (err) {
console.error('[消费历史分类器] processAll 失败:', err);
showToast(t('analyzeFail').replace('{msg}', err.message), 'error');
} finally {
state.isProcessing = false;
}
}
const debouncedProcess = () => { clearTimeout(state.timers.debounce); state.timers.debounce = setTimeout(processAll, DEBOUNCE_MS); };
function accumulateRow(row, vs) {
const cat = row.dataset.category; if (!cat) return;
const cnt = parseInt(row.dataset.marketCount) || 1;
vs.counts.set(cat, vs.counts.get(cat) + cnt);
vs.counts.set('total', calcTotal(cat, cnt, vs.counts.get('total')));
const amt = parseFloat(row.dataset.amount) || 0, curId = row.dataset.currency;
if (curId === primaryCurrency.id) {
vs.amounts.set(cat, vs.amounts.get(cat) + amt);
vs.amounts.set('total', calcTotal(cat, amt, vs.amounts.get('total')));
const pm = row.dataset.payment;
if (pm === 'mixed') {
for (const p of JSON.parse(row.dataset.paymentParts || '[]')) { addToMap(vs.amtByPm, p.pm, cat, p.amt); addToMap(vs.cntByPm, p.pm, cat, cnt); }
} else if (pm) { addToMap(vs.amtByPm, pm, cat, amt); addToMap(vs.cntByPm, pm, cat, cnt); }
} else { addToMap(vs.amtByCur, curId, cat, amt); addToMap(vs.cntByCur, curId, cat, cnt); }
}
function addToMap(map, key, cat, val) {
const m = map.get(key) || (map.set(key, makeAmountMap()), map.get(key));
m.set(cat, m.get(cat) + val); m.set('total', calcTotal(cat, val, m.get('total')));
}
function getOrCreateSubRow(cacheMap, key, { rowClass, datasetKey, label, labelClass, labelColor, flagHtml }) {
let info = cacheMap.get(key); if (info) return info;
const elMap = new Map(), row = buildStatRow(elMap);
row.classList.add('sub-stat-row', rowClass); row.dataset[datasetKey] = key;
appendRowLabel(row, label, labelClass, labelColor, flagHtml);
for (const [catKey, el] of elMap) {
el.style.cursor = 'pointer'; el.title = t('subFilterClick') || '';
el.addEventListener('click', e => {
e.stopPropagation();
const sf = state.subFilter;
if (sf && sf.type === datasetKey && sf.key === key && sf.category === catKey) state.subFilter = null;
else { const nf = { type: datasetKey, key, category: catKey }; if (datasetKey === 'payment') nf.currencyKey = primaryCurrency.id; state.subFilter = nf; }
applyView();
});
}
if (state.containerEl) state.containerEl.appendChild(row);
info = { rowEl: row, elMap }; cacheMap.set(key, info); return info;
}
function appendRowLabel(row, text, cls, color, flagHtml) {
const label = document.createElement('span');
label.className = `stat-item ${cls}`;
label.style.cssText = `width:auto;padding:0 6px;font-size:12px;font-style:normal;cursor:default;user-select:none;color:${color};display:flex;align-items:center;gap:4px`;
if (flagHtml) { const f = document.createElement('span'); f.innerHTML = flagHtml; f.style.cssText = 'display:inline-flex;align-items:center;flex-shrink:0'; label.appendChild(f); }
const txt = document.createElement('span'); txt.textContent = text; label.appendChild(txt);
row.appendChild(label);
}
const formatVal = (val, symbol, isCount) => isCount ? (val || '-') : (val > 0 ? `${symbol}${val.toFixed(2)}` : '-');
const hasNonZero = (map, key) => [...(map?.get(key)?.values() || [])].some(v => v > 0);
const anyPositive = (sMap, vMap, key, useSearch) => useSearch ? hasNonZero(vMap, key) : [...(sMap.get(key)?.values() || [])].some(v => v > 0);
function refreshStats(visRows, vs) {
const useSearch = !!state.searchQuery;
const activeCurIds = new Set([...state.amountsByCurrency.keys()].filter(id => anyPositive(state.amountsByCurrency, vs.amtByCur, id, useSearch) || anyPositive(state.countsByCurrency, vs.cntByCur, id, useSearch)));
const activePmIds = new Set([...state.amountsByPayment.keys()].filter(id => anyPositive(state.amountsByPayment, vs.amtByPm, id, useSearch) || anyPositive(state.countsByPayment, vs.cntByPm, id, useSearch)));
const hasExpandable = activePmIds.size > 0 || activeCurIds.size > 0;
for (const m of [state.secondaryStats, state.paymentStats]) for (const [, info] of m) if (info.rowEl) info.rowEl.style.display = 'none';
state.containerEl?.querySelectorAll('.shc-primary-sep').forEach(el => el.style.display = 'none');
const ds = state.showAmounts
? { primary: state.amounts, search: vs.amounts, byPm: vs.amtByPm, byCur: vs.amtByCur, pmState: state.amountsByPayment, curState: state.amountsByCurrency, symbol: primaryCurrency.symbol, isCount: false }
: { primary: state.counts, search: vs.counts, byPm: vs.cntByPm, byCur: vs.cntByCur, pmState: state.countsByPayment, curState: state.countsByCurrency, symbol: '', isCount: true };
const getPrimary = key => useSearch ? (ds.search.get(key) || 0) : ds.primary.get(key);
if (state.statTotalEl) state.statTotalEl.textContent = formatVal(getPrimary('total'), ds.symbol, ds.isCount);
for (const c of CATEGORIES) { const el = state.statCountEls.get(c.id); if (el) el.textContent = formatVal(getPrimary(c.id), ds.symbol, ds.isCount); }
if (state.secToggleBtn) { state.secToggleBtn.style.display = hasExpandable ? '' : 'none'; state.secToggleBtn.textContent = state.showSecondaryRow ? '▾' : '▸'; }
if (state.showSecondaryRow) {
const orderedRows = [];
for (const pm of PM_METHODS) {
if (!activePmIds.has(pm.id)) continue;
const info = getOrCreateSubRow(state.paymentStats, pm.id, { rowClass: 'payment-stat-row', datasetKey: 'payment', label: t('pm' + pm.id.charAt(0).toUpperCase() + pm.id.slice(1)), labelClass: 'pm-label', labelColor: pm.color, flagHtml: pmIconHtml(pm.id) });
const getVal = key => useSearch ? (ds.byPm?.get(pm.id)?.get(key) || 0) : (ds.pmState.get(pm.id)?.get(key) || 0);
fillStatRow(info, getVal, ds.symbol, ds.isCount);
orderedRows.push(info.rowEl);
}
let firstCur = true;
for (const curId of activeCurIds) {
const cur = CURRENCY_MAP.get(curId); if (!cur) continue;
if (firstCur) {
const sep = document.createElement('div');
sep.className = 'shc-primary-sep';
sep.style.cssText = 'height:2px;background:linear-gradient(to right,transparent,var(--border),transparent);margin:6px 0;border-radius:1px';
if (state.containerEl) { state.containerEl.appendChild(sep); orderedRows.push(sep); }
firstCur = false;
}
const info = getOrCreateSubRow(state.secondaryStats, cur.id, { rowClass: 'sec-stat-row', datasetKey: 'currency', label: cur.label, labelClass: 'sec-currency-label', labelColor: '#8a9ba8', flagHtml: curFlagHtml(cur.id, 16, 12, 0) });
const getVal = key => useSearch ? (ds.byCur?.get(cur.id)?.get(key) || 0) : (ds.curState.get(cur.id)?.get(key) || 0);
fillStatRow(info, getVal, state.showAmounts ? cur.symbol : '', ds.isCount);
orderedRows.push(info.rowEl);
}
for (const rowEl of orderedRows) { if (state.containerEl) state.containerEl.appendChild(rowEl); rowEl.style.display = 'flex'; }
const sf = state.subFilter;
const highlight = (info, dk) => { for (const [catKey, el] of info.elMap) { const act = sf && sf.type === dk && sf.key === info.rowEl.dataset[dk] && sf.category === catKey; el.style.color = act ? 'var(--accent)' : ''; el.style.textDecoration = act ? 'underline' : ''; } };
for (const [, info] of state.paymentStats) highlight(info, 'payment');
for (const [, info] of state.secondaryStats) highlight(info, 'currency');
}
}
const fillStatRow = (info, getVal, symbol, isCount) => { for (const k of ['total', ...CATEGORIES.map(c => c.id)]) { const el = info.elMap.get(k); if (el) el.textContent = formatVal(getVal(k), symbol, isCount); } };
// ==================== 筛选与分页 ====================
function applyFilter(type) {
const tb = getTBody(); if (!tb) return;
const oldCls = state.currentFilter !== 'all' && `filter-${state.currentFilter}`;
const newCls = type !== 'all' && `filter-${type}`;
if (oldCls) tb.classList.remove(oldCls); if (newCls) tb.classList.add(newCls);
state.currentFilter = type; state.subFilter = null; state.currentPage = 1; applyView();
}
function applyView() { if (getTBody()) applyViewFilters(getDataRows(), makeViewStats()); }
function updatePagerUI() {
if (!state.prevBtnEl) return;
if (state.showAllMode) {
state.showAllBtnEl.textContent = t('pagedView'); state.showAllBtnEl.classList.add('active-mode');
state.prevBtnEl.disabled = state.nextBtnEl.disabled = true;
if (state.pageInputEl) { state.pageInputEl.value = 1; state.pageInputEl.disabled = true; }
} else {
state.showAllBtnEl.textContent = t('showAll'); state.showAllBtnEl.classList.remove('active-mode');
state.prevBtnEl.disabled = state.currentPage <= 1; state.nextBtnEl.disabled = state.currentPage >= state.totalPages;
if (state.pageInputEl) { state.pageInputEl.value = state.currentPage; state.pageInputEl.max = state.totalPages; state.pageInputEl.disabled = false; }
}
if (state.pageTotalEl) state.pageTotalEl.textContent = state.totalPages;
}
// ==================== 加载更多监听 ====================
function autoClickLoadMore() {
const btn = document.querySelector('#load_more_button');
if (!btn || btn.offsetParent === null || btn.style.display === 'none' || btn.disabled) return;
btn.click();
}
function styleLoadMoreBtn(btn) {
if (!btn) return;
btn.className = 'load-more-btn';
btn.style.cssText = 'width:350px;height:36px;box-sizing:border-box;padding:0;font-size:14px;display:flex;align-items:center;justify-content:center;white-space:nowrap;position:absolute;right:0;z-index:99999';
btn.addEventListener('click', e => e.stopPropagation());
const statRow = state.containerEl?.querySelector('.stat-row');
if (statRow) statRow.appendChild(btn);
}
function interceptLoadMore() {
const btn = document.querySelector('#load_more_button');
if (!btn || btn.dataset.intercepted) return;
btn.dataset.intercepted = 'true';
styleLoadMoreBtn(btn);
btn.addEventListener('click', () => { waitForDataStable().then(() => { processAll(); setTimeout(autoClickLoadMore, DEBOUNCE_MS); }); });
}
function startLoadMoreObserver() {
const parent = document.querySelector('.wallet_history_table')?.parentElement || document.querySelector('#main_content');
if (!parent || parent.dataset.loadMoreObs) return;
parent.dataset.loadMoreObs = 'true';
const obs = new MutationObserver(() => interceptLoadMore());
obs.observe(parent, { childList: true, subtree: true });
state.disposers.push(() => obs.disconnect());
}
function waitForDataStable() {
return new Promise(resolve => {
const tb = getTBody(); if (!tb) { resolve(); return; }
let settleTimer = null, safetyTimer = null;
const done = () => { clearTimeout(safetyTimer); clearTimeout(settleTimer); obs.disconnect(); const ric = window.requestIdleCallback || (cb => setTimeout(cb, 1)); ric(() => resolve()); };
const obs = new MutationObserver(() => {
clearTimeout(settleTimer);
settleTimer = setTimeout(done, SETTLE_MS);
});
obs.observe(tb, { childList: true, subtree: true });
safetyTimer = setTimeout(done, SAFETY_TIMEOUT);
});
}
function startTableObserver() {
if (state.observers.table) state.observers.table.disconnect();
const tb = getTBody(); if (!tb) return;
state.observers.table = new MutationObserver(muts => { if (muts.some(m => Array.from(m.addedNodes).some(n => n.nodeType === 1 && n.nodeName === 'TR'))) { invalidateDataRowsCache(); debouncedProcess(); } });
state.observers.table.observe(tb, { childList: true });
state.disposers.push(() => { state.observers.table?.disconnect(); clearTimeout(state.timers.debounce); });
}
// ==================== 数据导出 ====================
const PM_LABELS = { alipay: () => t('pmAlipay'), wechat: () => t('pmWechat'), wallet: () => t('pmWallet'), unionpay: () => t('pmUnionpay'), paypal: () => t('pmPaypal'), mastercard: () => t('pmMastercard'), visa: () => t('pmVisa'), skrill: () => t('pmSkrill'), other: () => t('pmOther') };
function getRowPayments(row) {
const pm = row.dataset.payment; if (!pm) return [];
const mk = (p, a) => ({ method: PM_LABELS[p]?.() ?? p, amount: fmtAmt(a) });
if (pm === 'mixed') { try { return JSON.parse(row.dataset.paymentParts || '[]').map(p => mk(p.pm, p.amt)); } catch { return []; } }
return [mk(pm, parseFloat(row.dataset.amount) || 0)];
}
function getExportData() {
const data = [];
for (const row of getDataRows()) {
if (!row.dataset.category || row.dataset.category === 'other') continue;
const rawDate = $dateText(row);
const date = fmtDate(parseDateStr(rawDate)) || rawDate;
const items = ($itemsText(row)).split(/[\n\t]+/).map(s => s.trim()).filter(Boolean);
const total = $total(row)?.textContent.replace(/\s+/g, ' ').trim() || '';
const walletChange = $walletChange(row)?.textContent.replace(/\s+/g, ' ').trim() || '';
const payments = getRowPayments(row);
const catLabel = t(row.dataset.category);
const typeText = $typeText(row);
const actionMatch = typeText.match(/^(购买|購買|退款|礼物购买|禮物購買|游戏内购买|遊戲內物品購買|市场交易|市集交易|充值|转换|轉換|Purchase|Refund|Gift Purchase|In-Game Purchase|Market Transaction|Top-up|Wallet|Convert)/im);
const action = actionMatch ? actionMatch[1] : '';
const itemsList = items.length > 0 ? items : [''];
const pmFields = {};
for (let i = 0; i < 3; i++) { pmFields[`payment_method_${i + 1}`] = payments[i]?.method || ''; pmFields[`payment_amount_${i + 1}`] = payments[i]?.amount || ''; }
for (let i = 0; i < itemsList.length; i++) {
data.push({ date, item: itemsList[i], action, category: catLabel, total, wallet_change: walletChange, ...pmFields, is_split_first: i === 0, split_count: itemsList.length });
}
}
return data;
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url; a.download = filename; a.style.display = 'none';
document.body.appendChild(a); a.click();
setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 500);
}
function doExport(format) {
try {
const data = getExportData(); if (!data.length) { showToast(t('exportNoData'), 'error'); return; }
const now = new Date();
const ts = fmtDate(now) + '_' + [now.getHours(), now.getMinutes(), now.getSeconds()].map(v => String(v).padStart(2, '0')).join('');
if (format === 'csv') {
const headers = [t('csvDate'), t('csvItem'), t('csvAction'), t('csvCategory'), t('csvTotal'), t('csvWalletChange'), t('csvPm1'), t('csvPa1'), t('csvPm2'), t('csvPa2'), t('csvPm3'), t('csvPa3')];
const esc = s => `"${String(s).replace(/"/g, '""')}"`;
const csv = [headers.join(','), ...data.map(r => [r.date, r.item, r.action, r.category, r.total, r.wallet_change,
r.payment_method_1, r.payment_amount_1, r.payment_method_2, r.payment_amount_2, r.payment_method_3, r.payment_amount_3].map(esc).join(','))].join('\n');
downloadBlob(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }), `steam-history-${ts}.csv`);
} else {
const clean = data.map(r => ({
date: r.date, item: r.item, action: r.action, category: r.category, total: r.total, wallet_change: r.wallet_change,
payments: [1,2,3].map(i => r[`payment_method_${i}`] && r[`payment_amount_${i}`] ? { method: r[`payment_method_${i}`], amount: r[`payment_amount_${i}`] } : null).filter(Boolean),
...(r.split_count > 1 ? { split_info: { is_first: r.is_split_first, total_items: r.split_count } } : {})
}));
downloadBlob(new Blob([JSON.stringify(clean, null, 2)], { type: 'application/json;charset=utf-8;' }), `steam-history-${ts}.json`);
}
showToast(t('exportSuccess'), 'success');
} catch (err) { console.error(`Export ${format} failed:`, err); showToast(t('exportFail') + ': ' + err.message, 'error'); }
}
// ==================== 图标集 ====================
const _svgA = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
const _s18 = `