出售中...
';
try {
await sellItem({ appid: data.appid, contextid: data.contextid, assetid: data.assetid, priceCents });
toast('已上架: ' + (data.name || data.market_hash_name), 'success');
if (statusEl) statusEl.innerHTML = ' 里,
// markdown-reader 会一次性生成 4 万多个 ,预览明显卡顿;
// 部分渲染器还会截断超长表格。分段后每段独立成表,观感与性能都更好。
const CHUNK = 500;
const headRow = '| # | 物品 | 游戏 | 类型 | 价格 | 交易时间 | 上架时间 |';
const sepRow = '| --- | --- | --- | --- | ---: | --- | --- |';
const totalChunks = Math.max(1, Math.ceil(rows.length / CHUNK));
for (let c = 0; c < rows.length; c += CHUNK) {
const part = rows.slice(c, c + CHUNK);
if (totalChunks > 1) L.push(`### 第 ${Math.floor(c / CHUNK) + 1} / ${totalChunks} 段(第 ${c + 1} - ${c + part.length} 条)`);
L.push('');
L.push(headRow);
L.push(sepRow);
part.forEach((r, k) => {
const i = c + k;
L.push(`| ${i + 1} | ${mdCell(r.name)} | ${mdCell(r.game)} | ${r.actionLabel} | ${r.priceCents ? m(r.priceCents) : ''} | ${mhFmtTs(r.tradeTs) || '-'} | ${mhFmtTs(r.listedTs) || '-'} |`);
});
L.push('');
}
L.push('---');
L.push('');
L.push(`> 手续费按当前钱包费率估算,非 Steam 结算值。年份由倒序分页跨年推断得出,AppID 尽力匹配、可能为空。`);
return L.join('\n');
}
/**
* Markdown 单元格转义 —— 标准 GFM 反斜杠转义。
*
* 为什么用标准 `\|` 而不是实体 `|`:
* 历史上为了迁就 scripts-tools/markdown-reader 不认反斜杠的缺陷而改用实体,
* 代价是源码视图里全是 `|` 这类噪声。v1.6.7 起 reader 已支持标准转义,
* 故回归 GFM 标准写法 —— 通用 Markdown 工具链(GitHub / VS Code / Obsidian /
* marked.js 等)都认它,源码也更好读。
*
* 两条兼容保证:
* - 新导出(本函数,标准转义)→ 新 reader ✓
* - v1.6.6 之前的旧导出(实体 |)→ 新 reader ✓(escapeHtml 保留已有实体)
*
* 字符分工:
* & < > → HTML 实体(HTML 上下文,任何渲染器下都安全)
* 其余语法符 → 反斜杠转义(Markdown 标准)
*
* 单次扫描完成映射,避免 & 先被转成 & 后自己插入的实体又被二次编码。
*/
const MD_CELL_ENTITIES = {
'&': '&',
'<': '<',
'>': '>',
};
const MD_CELL_ESCAPES = {
'\\': '\\\\',
'|': '\\|',
'`': '\\`',
'*': '\\*',
'_': '\\_',
'~': '\\~',
'[': '\\[',
']': '\\]',
};
const MD_CELL_RE = /[&<>\\|`*_~[\]]/g;
function mdCell(s) {
const str = String(s == null ? '' : s);
if (!str) return '';
return str
.replace(/\r\n?|\n/g, ' ') // 换行会截断表格行
.replace(MD_CELL_RE, (c) =>
MD_CELL_ENTITIES[c] !== undefined ? MD_CELL_ENTITIES[c] : MD_CELL_ESCAPES[c]);
}
function mhCurrencyCode() {
return getWalletInfo().currency_code || 'USD';
}
/**
* 执行导出。
* @param {object} opt
* format: 'csv' | 'tsv' | 'json' | 'md'
* rows: 导出的记录集,缺省为全部已索引记录
* label: 文件名后缀标注(如"筛选结果")
*/
function mhRunExport(opt) {
opt = opt || {};
const rows = opt.rows && opt.rows.length ? opt.rows : mhState.rows;
if (rows.length === 0) { toast('没有可导出的记录', 'warning'); return false; }
const format = opt.format || mhState.format || 'csv';
const summary = mhSummarize(rows);
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const suffix = opt.label ? '-' + opt.label : '';
const base = `steam-market-history${suffix}-${stamp}`;
const ext = { csv: 'csv', tsv: 'tsv', json: 'json', md: 'md' }[format] || 'csv';
const mime = {
csv: 'text/csv', tsv: 'text/tab-separated-values',
json: 'application/json', md: 'text/markdown',
}[format] || 'text/csv';
const content = {
csv: () => mhToCsv(rows, summary),
tsv: () => mhToTsv(rows),
json: () => mhToJson(rows, summary),
md: () => mhToMarkdown(rows, summary),
}[format]();
mhDownload(`${base}.${ext}`, content, mime);
toast(`已导出 ${rows.length} 条记录(${format.toUpperCase()})`, 'success');
return true;
}
/** 确保已索引数据:必要时先征得用户同意再抓取 */
async function mhEnsureData(actionName) {
if (mhState.fetching) { toast('正在抓取中,请稍候…', 'info'); return false; }
if (mhState.rows.length > 0) return true;
const total = mhState.totalCount;
const est = mhEstimateSeconds();
const detail = total
? `共 ${total} 条记录,每页 ${mhPageSize()} 条逐页抓取,预计约 ${est} 秒。`
: '记录较多时可能需要数分钟。抓取期间请勿切换页面。';
const ok = await showConfirmDialog(
`${actionName}前需要先向 Steam 抓取交易记录。
`
+ `${detail} `
+ `抓取结果会缓存在本地 12 小时内,导出与汇总都基于该缓存。`,
{ title: '抓取市场交易记录', okText: '开始抓取', okClass: 'sme-dialog-blue' }
);
if (!ok) return false;
await mhFetchAll();
return mhState.rows.length > 0;
}
// ---------- 分析窗口(v1.6.1) ----------
// 结构参考 Steam-License-Classifier 的 ensureAnalysisShell / activateAnalysisPanel
// (顶部标签栏 + 记住上次标签 + 标题栏主题色跟随)与 Steam-Spending-History-Classifier
// 的 renderModal 通用面板渲染器;图表部分参考其 buildGrid / 折线 / 环形 / 日历热力。
// v1.6.5:榜单从概览页拆出为独立「排行」页。
// 概览页此前把 13+ 张卡 + 两张图表 + 一张可内部滚动的 Top20 表格塞在一起,
// 外层 .sme-mh-panels 与内层 .sme-mh-table-wrap 同时可滚,出现双滚动条且页面极长。
const MH_TABS = [
{ prefix: 'overview', icon: 'chart', label: '概览', title: '概览', color: '#66c0f4' },
{ prefix: 'trend', icon: 'trendingUp', label: '趋势', title: '交易曲线趋势', color: '#4caf50' },
{ prefix: 'rank', icon: 'queue', label: '排行', title: '成交排行 Top 20', color: '#38bdf8' },
{ prefix: 'calendar', icon: 'calendar', label: '日历', title: '交易日历热力', color: '#8b5cf6' },
{ prefix: 'ledger', icon: 'list', label: '明细', title: '交易明细', color: '#3b82f6' },
{ prefix: 'export', icon: 'download', label: '导出', title: '导出数据', color: '#10b981' },
];
const MH_LEDGER_PAGE = 50;
const mhUi = {
lastTab: 'overview',
trendMetric: 'amount', // amount | count
trendCumulative: false,
// v1.6.5:排行页的分组维度与排序依据
rankDim: 'item', // item | game | month
rankSort: 'amount', // amount | count
// v1.6.3:默认全选。金额与笔数分列左右双轴,全选也能读
trendSeries: { soldCents: true, boughtCents: true, netCents: true, feeCents: true, count: true },
ledgerType: 'all',
ledgerDim: 'all', // all | game | item | month(维度筛选,替代下拉框)
ledgerValue: '',
ledgerQuery: '',
ledgerSort: 'tradeTs',
ledgerDesc: true,
ledgerPage: 1,
exportFormat: null, // null = 跟随工具条选择
};
let _mhShell = null;
const _mhMeta = {};
function mhTabIcon(name) {
return ICONS[name] || ICONS.chart;
}
function ensureMhShell() {
if (_mhShell) return _mhShell;
const modal = document.createElement('div');
modal.id = 'sme-mh-modal';
modal.className = 'sme-mh-modal';
const tabsHtml = MH_TABS.map((t) =>
``).join('');
modal.innerHTML = `
${tabsHtml}
${ICONS.alert}
`;
document.body.appendChild(modal);
const hide = () => modal.classList.remove('visible');
modal.addEventListener('click', (e) => { if (e.target === modal) hide(); });
modal.querySelector('#sme-mh-close').addEventListener('click', hide);
// 阻止滚轮穿透到页面
modal.querySelector('.sme-mh-win').addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
modal.querySelectorAll('.sme-mh-tab').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
mhShowPanel(btn.dataset.tab);
});
});
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); hide(); }
else if (e.key === 'Tab') {
const items = [...modal.querySelectorAll('button')].filter((b) => b.offsetParent !== null);
if (!items.length) return;
const idx = items.indexOf(document.activeElement);
const next = e.shiftKey
? items[(idx <= 0 ? items.length : idx) - 1]
: items[(idx + 1) % items.length];
e.preventDefault();
if (next) next.focus();
}
};
document.addEventListener('keydown', onKey, true);
modal._dispose = () => document.removeEventListener('keydown', onKey, true);
_mhShell = {
modal,
panelsEl: modal.querySelector('#sme-mh-panels'),
iconEl: modal.querySelector('#sme-mh-hi'),
titleEl: modal.querySelector('#sme-mh-ht'),
noteEl: modal.querySelector('#sme-mh-note'),
headerEl: modal.querySelector('#sme-mh-header'),
};
return _mhShell;
}
function activateMhPanel(prefix) {
const shell = ensureMhShell();
const tab = MH_TABS.find((t) => t.prefix === prefix) || MH_TABS[0];
const meta = _mhMeta[prefix] || {};
shell.modal.classList.add('visible');
shell.panelsEl.querySelectorAll('.sme-mh-view').forEach((p) => p.classList.toggle('active', p.dataset.panel === prefix));
shell.modal.querySelectorAll('.sme-mh-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === prefix));
shell.headerEl.style.background = `linear-gradient(135deg,${tab.color}26,rgba(13,15,21,0.9))`;
shell.headerEl.style.borderBottomColor = `${tab.color}40`;
shell.iconEl.style.color = tab.color;
shell.iconEl.innerHTML = mhTabIcon(tab.icon);
shell.titleEl.textContent = meta.title || tab.title;
shell.noteEl.textContent = meta.note || '';
mhUi.lastTab = prefix;
}
/** 通用面板渲染器:建/复用面板 → 写 HTML → postRender → 显示并激活 */
function mhRenderPane(prefix, note, buildFn, postRender) {
const shell = ensureMhShell();
const tab = MH_TABS.find((t) => t.prefix === prefix) || MH_TABS[0];
_mhMeta[prefix] = { title: tab.title, note: note };
let panel = shell.panelsEl.querySelector(`.sme-mh-view[data-panel="${prefix}"]`);
if (!panel) {
panel = document.createElement('div');
panel.className = 'sme-mh-view';
panel.dataset.panel = prefix;
const body = document.createElement('div');
body.className = 'sme-mh-pbody';
body.id = `sme-mh-${prefix}-body`;
panel.appendChild(body);
shell.panelsEl.appendChild(panel);
}
const bodyEl = panel.querySelector('.sme-mh-pbody');
// 面板重建前清理上一轮注册的事件
if (bodyEl._cleanup) { try { bodyEl._cleanup(); } catch (e) {} bodyEl._cleanup = null; }
let html;
try { html = buildFn(); }
catch (err) {
W('MH pane render failed:', prefix, err);
html = `面板渲染失败:${escapeHtml(err.message)} `;
}
bodyEl.innerHTML = html;
if (postRender) postRender(bodyEl);
activateMhPanel(prefix);
return bodyEl;
}
function mhShowPanel(prefix) {
if (mhState.rows.length === 0) return;
const s = mhSummarize(mhState.rows);
const code = mhCurrencyCode();
const wi = getWalletInfo();
const money = (c) => formatCentsStr(c, code);
const truncated = mhState.totalCount > s.count
? ` Steam 报告共 ${mhState.totalCount} 条,当前已索引 ${s.count} 条。` : '';
const baseNote = '手续费按当前钱包费率(Steam '
+ Math.round(wi.wallet_fee_percent * 100) + '% + 发行商 '
+ Math.round(wi.wallet_publisher_fee_percent_default * 100) + '%)估算,非 Steam 结算值;'
+ '年份由倒序分页跨年推断得出;AppID 为尽力匹配,可能为空。' + truncated;
switch (prefix) {
case 'overview': return mhPaneOverview(s, money, baseNote);
case 'rank': return mhPaneRank(s, money, baseNote);
case 'trend': return mhPaneTrend(s, money, baseNote);
case 'calendar': return mhPaneCalendar(s, money, baseNote);
case 'ledger': return mhPaneLedger(s, money, baseNote);
case 'export': return mhPaneExport(s, money, baseNote);
default: return mhPaneOverview(s, money, baseNote);
}
}
function openMhAnalysis() {
mhShowPanel(mhUi.lastTab || 'overview');
}
// ---------- 通用图表零件 ----------
function mhAxisNum(cents, metric) {
if (metric === 'count') return String(Math.round(cents));
const code = mhCurrencyCode();
if (CURRENCY_CODES_TO_ROUND.includes(code)) return String(Math.round(cents / 100));
const n = cents / 100;
if (n >= 10000) return (n / 1000).toFixed(0) + 'k';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
if (n >= 10) return n.toFixed(0);
return n.toFixed(1);
}
/** 横向网格 + Y 轴刻度(参考 buildGrid) */
function mhGrid(min, max, pL, pR, pT, cH, metric) {
let g = '';
for (let i = 0; i <= 5; i++) {
const v = min + (max - min) * (i / 5);
const y = pT + cH - (cH * i / 5);
g += ``
+ `${mhAxisNum(v, metric)}`;
}
return g;
}
function mhStatCard(label, value, cls, icon, hint, accent) {
const tone = /^#[0-9a-fA-F]{6}$/.test(accent || '') ? ` style="--accent:${accent}"` : '';
return ``
+ (icon ? ` ${icon}` : '')
+ ` ${escapeHtml(label)} `
+ ` ${escapeHtml(value)} `
+ (hint ? ` ${escapeHtml(hint)} ` : '')
+ ' ';
}
function mhPaneOverview(s, money, note) {
const code = mhCurrencyCode();
const period = s.firstTs ? `${mhFmtTs(s.firstTs).slice(0, 10)} ~ ${mhFmtTs(s.lastTs).slice(0, 10)}` : '未知';
const hold = s.hold;
const activeShare = s.spanDays ? Math.round(s.activeDays / s.spanDays * 1000) / 10 : 0;
const avgBuyCents = s.bought.count ? Math.round(s.bought.grossCents / s.bought.count) : 0;
// 阅读顺序:汇总 → 买入 → 卖出。同类指标排在一起。
const cards = [
mhStatCard('记录总数', String(s.count), '', ICONS.layers, '', '#f5a524'),
mhStatCard('统计天数', `${s.spanDays} 天`, '', ICONS.clock, period, '#38bdf8'),
mhStatCard('活跃天数', `${s.activeDays} 天`, '', ICONS.check, s.spanDays ? `占统计天数 ${activeShare}%` : '', '#22d3ee'),
mhStatCard('日均 / 月均笔数', `${s.perDay} / ${s.perMonth}`, '', ICONS.chart, '', '#60a5fa'),
mhStatCard('净收益', money(s.netCents), s.netCents >= 0 ? 'sme-up' : 'sme-down', ICONS.trendingUp, '', '#2dd4bf'),
mhStatCard('ROI(净收入 ÷ 买入额)', s.roi === null ? '—' : s.roi + '%', s.roi === null ? '' : (s.roi >= 0 ? 'sme-up' : 'sme-down'), ICONS.trendingUp, '', s.roi !== null && s.roi < 0 ? '#f472b6' : '#4ade80'),
mhStatCard('买入笔数', String(s.bought.count), '', ICONS.trendingDown, '', '#fb7185'),
mhStatCard('买入总额', money(s.bought.grossCents), '', ICONS.bag, '', '#f87171'),
mhStatCard('买入均价', money(avgBuyCents), '', ICONS.tag, s.bought.count ? `${s.bought.count} 笔` : '暂无买入', '#f472b6'),
mhStatCard('卖出笔数', String(s.sold.count), '', ICONS.trendingUp, '', '#4ade80'),
mhStatCard('卖出总额', money(s.sold.grossCents), '', ICONS.sell, '', '#34d399'),
mhStatCard('估算手续费', money(s.sold.feeCents), '', ICONS.dollarSign, '', '#fbbf24'),
mhStatCard('卖出净收入', money(s.sold.netCents), 'sme-up', ICONS.check, '', '#10b981'),
mhStatCard('卖出均价', money(s.avgSoldCents), '', ICONS.tag, `中位 ${money(s.medianSoldCents)}`, '#a78bfa'),
mhStatCard('最高成交', money(s.maxSoldCents), '', ICONS.gem, `区间 ${money(s.minSoldCents)} ~ ${money(s.maxSoldCents)}`, '#818cf8'),
mhStatCard('平均持有时长', mhFmtDuration(hold.avgMs), '', ICONS.clock, hold.count ? `中位 ${mhFmtDuration(hold.medianMs)} · 样本 ${hold.count}` : '缺少可对比的上下架时间', '#c084fc'),
];
// v1.6.5:收支对比改为「双占比环 + 关键数字」紧凑布局。
// 原先只是一根细进度条,卡片被撑到 250px 高却只有 40px 内容,右侧价格分布
// 被挤成窄条。现在两侧卡片高度对齐、内容填满。
const inCents = s.sold.netCents, outCents = s.bought.grossCents;
const sum = Math.max(1, inCents + outCents);
const inPct = (inCents / sum * 100);
const donut = (() => {
const R = 52, C = 2 * Math.PI * R;
const inLen = C * (inPct / 100);
return ``;
})();
const flowRows = [
{ label: '卖出总额', v: s.sold.grossCents, c: '#4ade80', sub: `${s.sold.count} 笔` },
{ label: '减 估算手续费', v: -s.sold.feeCents, c: '#fbbf24', sub: `${Math.round(s.sold.feeCents / Math.max(1, s.sold.grossCents) * 1000) / 10}%` },
{ label: '卖出净收入', v: s.sold.netCents, c: '#4ade80', sub: '', strong: true },
{ label: '买入总额', v: s.bought.grossCents, c: '#f87171', sub: `${s.bought.count} 笔` },
{ label: '净收益', v: s.netCents, c: s.netCents >= 0 ? '#66c0f4' : '#f87171', sub: '', strong: true },
];
const bar = `
${donut}
${flowRows.map((r) => `
${escapeHtml(r.label)}
${r.sub ? `${escapeHtml(r.sub)}` : ''}
${r.v < 0 ? '−' : ''}${escapeHtml(money(Math.abs(r.v)))}
`).join('')}
`;
// 价格分布:对数分档。价格通常跨 3–4 个数量级(₹0.01 ~ ₹1826),
// 等宽分档会把 99% 的样本挤进第一档(实测 5263/5266),直方图完全失真。
let hist = '暂无价格数据 ';
if (s.priceBuckets.length) {
const maxC = Math.max(...s.priceBuckets.map((b) => b.count), 1);
const totalC = s.priceBuckets.reduce((a, b) => a + b.count, 0);
const fmtV = (c) => {
const n = c / 100;
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k';
return n >= 100 ? n.toFixed(0) : n.toFixed(n >= 1 ? 2 : 3);
};
hist = '' + s.priceBuckets.map((b) => {
const pct = (b.count / maxC) * 100;
const share = totalC ? (b.count / totalC * 100) : 0;
const range = fmtV(b.from) === fmtV(b.to) ? fmtV(b.from) : `${fmtV(b.from)}~${fmtV(b.to)}`;
return ` `
+ ` `
+ ` ${b.count} `
+ ` ${escapeHtml(fmtV(b.from))} `;
}).join('') + ' '
+ `成交价分布 · ${escapeHtml(code)} · 对数分档 ${s.priceBuckets.length} 档`
+ ` · 横轴为该档起始价 `;
}
return mhRenderPane('overview', note, () =>
`${cards.join('')} `
+ ``
);
}
// ---------- 面板:排行(v1.6.5 从概览拆出) ----------
const MH_RANK_DIMS = [
{ id: 'item', label: '按物品', unit: '件' },
{ id: 'game', label: '按游戏', unit: '个' },
{ id: 'month', label: '按月份', unit: '个月' },
];
const MH_RANK_SORTS = [
{ id: 'amount', label: '按金额' },
{ id: 'count', label: '按笔数' },
];
const MH_RANK_TOP = 20;
/** 汇总三个维度的排行数据(不受设置的 TopN 截断影响,这里要完整 Top20) */
function mhRankGroups(dim) {
const map = new Map();
const keyOf = (r) => {
if (dim === 'game') return r.game || '未知游戏';
if (dim === 'month') return r.tradeTs ? mhMonthKey(r.tradeTs) : '';
return r.name || '';
};
const extraOf = (r, g) => { if (dim === 'item') g.game = r.game || ''; };
for (const r of mhState.rows) {
const k = keyOf(r);
if (!k) continue;
let g = map.get(k);
if (!g) { g = { key: k, game: '', count: 0, soldCount: 0, boughtCount: 0, soldCents: 0, boughtCents: 0, firstTs: 0, lastTs: 0 }; map.set(k, g); }
extraOf(r, g);
g.count++;
if (r.tradeTs) { if (!g.firstTs || r.tradeTs < g.firstTs) g.firstTs = r.tradeTs; if (r.tradeTs > g.lastTs) g.lastTs = r.tradeTs; }
if (r.action === 'sold') { g.soldCount++; g.soldCents += r.priceCents; }
else if (r.action === 'bought') { g.boughtCount++; g.boughtCents += r.priceCents; }
}
for (const g of map.values()) {
g.totalCents = g.soldCents + g.boughtCents;
g.avgCents = g.soldCount ? Math.round(g.soldCents / g.soldCount) : 0;
}
return [...map.values()];
}
function mhPaneRank(s, money, note) {
const dim = MH_RANK_DIMS.some((d) => d.id === mhUi.rankDim) ? mhUi.rankDim : 'item';
const sortBy = mhUi.rankSort === 'count' ? 'count' : 'amount';
const all = mhRankGroups(dim);
const metric = (g) => (sortBy === 'count' ? g.count : g.totalCents);
const list = all.slice().sort((a, b) => metric(b) - metric(a) || b.count - a.count || a.key.localeCompare(b.key));
const shown = list.slice(0, MH_RANK_TOP);
const peak = shown.length ? Math.max(...shown.map((g) => metric(g)), 1) : 1;
const restCount = Math.max(0, list.length - shown.length);
const restMetric = all.slice(MH_RANK_TOP).reduce((a, g) => a + metric(g), 0);
const dimDef = MH_RANK_DIMS.find((d) => d.id === dim);
return mhRenderPane('rank', note, () => {
const dimBtn = (d) => ``;
const sortBtn = (x) => ``;
const toolbar = `
分组${MH_RANK_DIMS.map(dimBtn).join('')}
排序${MH_RANK_SORTS.map(sortBtn).join('')}
共 ${all.length} ${escapeHtml(dimDef.unit)},显示 Top ${shown.length}${restCount ? `(其余 ${restCount} ${escapeHtml(dimDef.unit)}合计 ${escapeHtml(sortBy === 'count' ? String(restMetric) : money(restMetric))})` : ''}
`;
if (!shown.length) return toolbar + '暂无符合条件的成交记录 ';
// 占比条:直观看出每个条目在当前排序依据下的分量
const rows = shown.map((g, i) => {
const m = metric(g);
const w = Math.max(1.5, (m / peak) * 100);
const head = dim === 'item'
? ` | ${escapeHtml(g.key)}${escapeHtml(g.game || '')} | `
: `${escapeHtml(g.key)} | `;
return `
| ${i + 1} |
${head}
|
${g.count} |
${g.soldCount} |
${g.boughtCount} |
${g.soldCents ? escapeHtml(money(g.soldCents)) : '—'} |
${g.boughtCents ? escapeHtml(money(g.boughtCents)) : '—'} |
${g.avgCents ? escapeHtml(money(g.avgCents)) : '—'} |
${g.firstTs ? escapeHtml(mhFmtTs(g.firstTs).slice(0, 10)) : '—'} |
`;
}).join('');
return toolbar
+ `
| # | ${escapeHtml(dimDef.label.replace('按', ''))} |
占比 |
笔数 | 卖出 | 买入 | 卖出金额 | 买入金额 | 卖出均价 | 首次成交 |
${rows}
`;
}, (bodyEl) => {
const bar = bodyEl.querySelector('.sme-mh-toolbar');
const onClick = (e) => {
const chip = e.target.closest('button.sme-mh-chip');
if (!chip) return;
if (chip.dataset.rdim) mhUi.rankDim = chip.dataset.rdim;
else if (chip.dataset.rsort) mhUi.rankSort = chip.dataset.rsort;
else return;
mhShowPanel('rank');
};
bar.addEventListener('click', onClick);
bodyEl._cleanup = () => bar.removeEventListener('click', onClick);
});
}
// ---------- 面板 2:趋势(曲线) ----------
// v1.6.3:金额类与笔数量级相差 4 个数量级,原先共用一个 Y 轴会把笔数压成直线。
// 改为**双 Y 轴**:左轴金额、右轴笔数,各自独立缩放,默认全选也能读。
const MH_TREND_SERIES = [
{ key: 'soldCents', label: '卖出金额', color: '#4ade80', axis: 'money' },
{ key: 'boughtCents', label: '买入金额', color: '#f87171', axis: 'money' },
{ key: 'netCents', label: '净额', color: '#66c0f4', axis: 'money' },
{ key: 'feeCents', label: '手续费', color: '#fbbf24', axis: 'money' },
{ key: 'count', label: '成交笔数', color: '#a78bfa', axis: 'count' },
];
/** 分位数裁剪的 Y 轴域:避免个别尖峰把其余数据压成一条线 */
function mhRobustDomain(values, includeZero) {
const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
if (!v.length) return { lo: 0, hi: 1, clipped: 0 };
const at = (q) => v[Math.min(v.length - 1, Math.max(0, Math.round((v.length - 1) * q)))];
let lo = at(0.02), hi = at(0.98);
if (includeZero) lo = Math.min(lo, 0);
if (hi === lo) hi = lo + Math.max(1, Math.abs(lo) * 0.1);
const pad = (hi - lo) * 0.1;
const dom = { lo: lo - pad, hi: hi + pad, clipped: 0 };
for (const x of v) if (x < dom.lo || x > dom.hi) dom.clipped++;
return dom;
}
function mhPaneTrend(s, money, note) {
const metric = mhUi.trendMetric;
const series = mhMonthlySeries(mhState.rows, true);
const vis = MH_TREND_SERIES.filter((x) => mhUi.trendSeries[x.key]);
const showMoney = vis.some((d) => d.axis === 'money');
const showCount = vis.some((d) => d.axis === 'count');
const build = () => {
if (!series.length) return '暂无可推断日期的记录
';
if (!vis.length) return '请至少勾选一条曲线
';
// 金额统一换算为货币单位,笔数保持原值
let data = series.map((x) => ({
month: x.month,
empty: !!x.empty,
money: {
soldCents: (x.soldCents || 0) / 100,
boughtCents: (x.boughtCents || 0) / 100,
netCents: (x.netCents || 0) / 100,
feeCents: (x.feeCents || 0) / 100,
},
count: x.count || 0,
}));
if (mhUi.trendCumulative) {
const cm = {}, cc = { count: 0 };
data = data.map((o) => {
for (const k of ['soldCents', 'boughtCents', 'netCents', 'feeCents']) cm[k] = (cm[k] || 0) + o.money[k];
cc.count += o.count;
return { month: o.month, empty: o.empty, money: Object.assign({}, cm), count: cc.count };
});
}
const moneyVals = [], countVals = [];
for (const o of data) {
for (const d of vis) {
if (d.axis === 'money') moneyVals.push(o.money[d.key]);
else countVals.push(o.count);
}
}
const mDom = showMoney ? mhRobustDomain(moneyVals, true) : null;
const cDom = showCount ? mhRobustDomain(countVals, false) : null;
const svgW = 1080, svgH = 480, pT = 52, pB = 64;
const pL = 78, pR = showCount ? 74 : 28;
const cW = svgW - pL - pR, cH = svgH - pT - pB;
const step = data.length > 1 ? cW / (data.length - 1) : 0;
const xOf = (i) => pL + i * step;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const yMoney = (v) => pT + cH - ((clamp(v, mDom.lo, mDom.hi) - mDom.lo) / (mDom.hi - mDom.lo)) * cH;
const yCount = (v) => pT + cH - ((clamp(v, cDom.lo, cDom.hi) - cDom.lo) / (cDom.hi - cDom.lo)) * cH;
const yOf = (d, o) => (d.axis === 'money' ? yMoney(o.money[d.key]) : yCount(o.count));
// 网格 + 左侧金额刻度
let grid = '';
for (let i = 0; i <= 5; i++) {
const y = pT + cH - (cH * i / 5);
grid += ``;
if (showMoney) {
const v = mDom.lo + (mDom.hi - mDom.lo) * (i / 5);
grid += `${mhAxisNum(Math.round(v * 100), 'amount')}`;
}
}
// 右侧笔数刻度
if (showCount) {
for (let i = 0; i <= 5; i++) {
const y = pT + cH - (cH * i / 5);
const v = cDom.lo + (cDom.hi - cDom.lo) * (i / 5);
grid += `${mhAxisNum(Math.round(v), 'count')}`;
}
}
// 轴标题与时间段同一行:左右是轴名,中间是区间,都落在绘图区上方
const activeMonths = series.filter((x) => !x.empty).length;
const rangeLabel = `${series[0].month} – ${series[series.length - 1].month} · ${activeMonths}/${series.length} 月`;
const midX = (pL + svgW - pR) / 2;
if (showMoney) grid += `金额 ${escapeHtml(mhCurrencyCode())}`;
if (showCount) grid += `笔数`;
grid += `${escapeHtml(rangeLabel)}`;
// 零基线(仅当两轴都跨越 0 时画在金额轴上)
let zeroLine = '';
if (showMoney && mDom.lo < 0 && mDom.hi > 0) {
const zy = yMoney(0).toFixed(1);
zeroLine = ``;
}
const clipId = 'sme-mh-clip';
const lines = vis.map((d) => {
const pts = data.map((o, i) => `${xOf(i).toFixed(1)},${yOf(d, o).toFixed(1)}`).join(' ');
const baseY = (d.axis === 'money' ? yMoney(Math.max(mDom.lo, 0)) : yCount(Math.max(cDom.lo, 0))).toFixed(1);
const area = `${pL},${baseY} ${pts} ${xOf(data.length - 1).toFixed(1)},${baseY}`;
return ``
+ ``
+ ``
+ ``;
}).join('');
const dots = data.length <= 40 ? vis.map((d) =>
data.map((o, i) => ``
+ `${escapeHtml(o.month)} ${escapeHtml(d.label)}: ${escapeHtml(d.axis === 'money' ? money(Math.round(o.money[d.key] * 100)) : String(o.count))}`).join('')).join('') : '';
// X 轴:稀疏标注年份 + 端点月份
let labels = '';
let lastKey = '';
data.forEach((o, i) => {
const x = xOf(i);
const parts = o.month.split('-');
const yy = parts[0], mm = parts[1];
if ((mm === '01' || i === 0) && lastKey !== yy) {
labels += `${yy}`;
lastKey = yy;
}
if (i === data.length - 1 || (mm !== '01' && data.length <= 14)) {
labels += `${+mm}月`;
}
});
const clipDef = ``;
const tip = (mDom && mDom.clipped) || (cDom && cDom.clipped)
? `Y 轴按 2%–98% 分位裁剪以突出主体走势,超出范围的点贴边显示(共 ${(mDom ? mDom.clipped : 0) + (cDom ? cDom.clipped : 0)} 个)
`
: '';
const plotFrame = ``;
// padding-bottom 与 viewBox 同比例,SVG 用 none 铺满这块区域,宽屏两侧不再留白
const plotRatio = (svgH / svgW * 100).toFixed(4);
return ``
+ `
`
+ `
'
+ tip;
};
return mhRenderPane('trend', note, () => {
const totals = { soldCents: 0, boughtCents: 0, netCents: 0, feeCents: 0, count: 0 };
for (const x of series) {
if (x.empty) continue;
totals.soldCents += x.soldCents || 0;
totals.boughtCents += x.boughtCents || 0;
totals.netCents += x.netCents || 0;
totals.feeCents += x.feeCents || 0;
totals.count += x.count || 0;
}
const hintOf = (d) => d.axis === 'count'
? `${totals.count}笔`
: money(Math.round(totals[d.key] || 0));
const toggles = MH_TREND_SERIES.map((d) =>
``).join('');
const metricBtn = (v, label) =>
``;
const cumBtn = ``;
return `
口径${metricBtn('amount', '金额优先')}${metricBtn('count', '笔数优先')}
曲线${toggles}
${cumBtn}
${build()}
`;
}, (bodyEl) => {
const chartEl = bodyEl.querySelector('#sme-mh-trend-chart');
const redraw = () => { chartEl.innerHTML = build(); };
const bar = bodyEl.querySelector('.sme-mh-toolbar');
const onClick = (e) => {
const btn = e.target.closest('button.sme-mh-chip');
if (!btn) return;
if (btn.dataset.metric) { mhUi.trendMetric = btn.dataset.metric; }
else if (btn.dataset.cum !== undefined) { mhUi.trendCumulative = !mhUi.trendCumulative; }
else if (btn.dataset.series) {
const k = btn.dataset.series;
mhUi.trendSeries[k] = !mhUi.trendSeries[k];
}
bar.innerHTML = bar.innerHTML; // no-op,保持引用
mhShowPanel('trend');
};
bar.addEventListener('click', onClick);
bodyEl._cleanup = () => bar.removeEventListener('click', onClick);
});
}
// ---------- 面板 4:日历热力 ----------
/**
* v1.6.4 日历色阶。
* 旧版用 rgba 透明度叠在深底上,最高才到 .95,格子与背景几乎融在一起;
* 且格内数字统一深色,在亮底上又看不清。现改为**实色 6 档递进**,
* 每档同时给出背景色与配套文字色,保证任意档位下数字都可读。
*/
const MH_CAL_SCALE = [
{ bg: '#16202b', fg: '#5d6f80' }, // 无记录:可见但明显为空
{ bg: '#123b52', fg: '#9fd4ee' }, // 1
{ bg: '#175a7d', fg: '#b8e2f7' }, // 2
{ bg: '#1a6e94', fg: '#e2f4ff' }, // 3(背景压深一档以满足 AA 对比)
{ bg: '#28a0cc', fg: '#06202e' }, // 4(转深色字)
{ bg: '#48c2e8', fg: '#052030' }, // 5
{ bg: '#86dcf5', fg: '#062535' }, // 6(峰值)
];
const MH_CAL_EMPTY = { bg: 'transparent', fg: 'transparent' };
function mhCalLevel(v, mx) {
if (!v || v <= 0) return -1;
const p = v / mx;
// 6 档等分;最低档 1,峰值落在最后一档
let lv = Math.ceil(p * 6);
if (lv < 1) lv = 1;
if (lv > 6) lv = 6;
return lv;
}
function mhPaneCalendar(s, money, note) {
const daily = new Map(s.daily.map((d) => [d.day, d]));
const years = [...new Set(s.daily.map((d) => d.day.slice(0, 4)))].sort();
const maxCount = Math.max(1, ...s.daily.map((d) => d.count));
const maxCents = Math.max(1, ...s.daily.map((d) => d.soldCents + d.boughtCents));
return mhRenderPane('calendar', note, () => {
if (!years.length) return '暂无可推断日期的记录,无法生成日历
';
const MD = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二'];
const buildYear = (y) => {
const first = new Date(Number(y), 0, 1);
const last = new Date(Number(y), 11, 31);
const startDay = (first.getDay() + 6) % 7; // 周一为第一列
const totalDays = Math.round((last - first) / 86400000) + 1;
let cells = '';
for (let i = 0; i < startDay; i++) cells += '';
for (let i = 0; i < totalDays; i++) {
const cd = new Date(first);
cd.setDate(cd.getDate() + i);
const key = y + '-' + String(cd.getMonth() + 1).padStart(2, '0') + '-' + String(cd.getDate()).padStart(2, '0');
const d = daily.get(key);
const n = d ? d.count : 0;
const lv = mhCalLevel(n, maxCount);
const c = lv < 0 ? MH_CAL_EMPTY : MH_CAL_SCALE[lv];
const tip = d
? `${key}|${d.count} 笔|卖出 ${money(d.soldCents)}|买入 ${money(d.boughtCents)}`
: `${key}|无记录`;
cells += ``
+ (n ? `${n}` : '') + '
';
}
return `
${escapeHtml(y)}
${MD.map((m) => `${m}`).join('')}
${['一', '二', '三', '四', '五', '六', '日'].map((w) => `${w}`).join('')}
${cells}
`;
};
const totalCents = s.daily.reduce((a, d) => a + d.soldCents + d.boughtCents, 0);
const activeCells = s.daily.length;
const legendCells = MH_CAL_SCALE.map((c) =>
``).join('');
return `
按笔数着色
少${legendCells}多
单日最多 ${maxCount} 笔 · 有成交 ${activeCells} 天 · `
+ `合计成交额 ${escapeHtml(money(totalCents))}(单日峰值 ${escapeHtml(money(maxCents))})
`
+ `${years.map(buildYear).join('')}
`;
});
}
// ---------- 面板 5:明细 ----------
const MH_LEDGER_DIMS = [
{ id: 'all', label: '全部' },
{ id: 'game', label: '按游戏' },
{ id: 'item', label: '按物品' },
{ id: 'month', label: '按月份' },
];
/** 维度 → 行上的取值键 */
function mhLedgerDimKey(dim, r) {
if (dim === 'game') return r.game || '未知游戏';
if (dim === 'item') return r.name || '';
if (dim === 'month') return r.tradeTs ? mhMonthKey(r.tradeTs) : '';
return '';
}
/** 当前维度下的候选取值,按成交额降序取前 N(供 chips 使用) */
function mhLedgerDimOptions(dim) {
if (dim === 'all') return [];
const cnt = new Map();
for (const r of mhState.rows) {
if (mhUi.ledgerType !== 'all' && r.action !== mhUi.ledgerType) continue;
const k = mhLedgerDimKey(dim, r);
if (!k) continue;
cnt.set(k, (cnt.get(k) || 0) + (r.action === 'sold' ? r.priceCents : 0));
}
return [...cnt.entries()]
// 成交额降序;金额相同时按名称升序,保证顺序可预期而不是随 Map 插入序漂移
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
.map(([k]) => k);
}
function mhFilteredRows() {
const q = mhUi.ledgerQuery.trim().toLowerCase();
let rows = mhState.rows.filter((r) => {
if (mhUi.ledgerType !== 'all' && r.action !== mhUi.ledgerType) return false;
if (mhUi.ledgerDim !== 'all' && mhUi.ledgerValue) {
const key = mhLedgerDimKey(mhUi.ledgerDim, r);
if (key !== mhUi.ledgerValue) return false;
}
if (q) {
const hay = `${r.name}\n${r.game}\n${r.counterpart || ''}\n${r.actionLabel}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
const key = mhUi.ledgerSort;
const dir = mhUi.ledgerDesc ? -1 : 1;
rows.sort((a, b) => {
let va = key === 'price' ? a.priceCents : key === 'name' ? a.name : (a.tradeTs || a.listedTs || 0);
let vb = key === 'price' ? b.priceCents : key === 'name' ? b.name : (b.tradeTs || b.listedTs || 0);
if (typeof va === 'string') return va.localeCompare(vb) * dir;
return (va - vb) * dir;
});
return rows;
}
function mhPaneLedger(s, money, note) {
const rows = mhFilteredRows();
const totalPages = Math.max(1, Math.ceil(rows.length / MH_LEDGER_PAGE));
if (mhUi.ledgerPage > totalPages) mhUi.ledgerPage = totalPages;
if (mhUi.ledgerPage < 1) mhUi.ledgerPage = 1;
const slice = rows.slice((mhUi.ledgerPage - 1) * MH_LEDGER_PAGE, mhUi.ledgerPage * MH_LEDGER_PAGE);
const opts = mhLedgerDimOptions(mhUi.ledgerDim);
const th = (key, label) =>
`${escapeHtml(label)}${mhUi.ledgerSort === key ? (mhUi.ledgerDesc ? ' ▾' : ' ▴') : ''} | `;
const body = slice.length ? slice.map((r) => {
const hold = (r.action === 'sold' && r.tradeTs && r.listedTs) ? r.tradeTs - r.listedTs : 0;
return `
| ${escapeHtml(r.name)} |
${escapeHtml(r.game || '')} |
${escapeHtml(r.actionLabel)} |
${r.priceCents ? escapeHtml(money(r.priceCents)) : '—'} |
${r.action === 'sold' && r.priceCents ? escapeHtml(money(priceBeforeFees(r.priceCents))) : '—'} |
${escapeHtml(mhFmtTs(r.tradeTs) || r.tradeDateText || '-')} |
${hold ? escapeHtml(mhFmtDuration(hold)) : '—'} |
${escapeHtml(r.counterpart || '')} |
`;
}).join('') : `没有符合当前筛选条件的记录 试试切换「类型」或点「重置」 |
`;
return mhRenderPane('ledger', note, () => {
const typeBtn = (v, label) => ``;
const dimBtn = (v, label) => ``; const pager = totalPages > 1
? `` : ``;
// 维度候选用 chips 呈现,不用下拉——高基数下列表会拉得很长且难以扫读
const TOPN = 14;
let valueRow = '';
if (mhUi.ledgerDim !== 'all') {
const shown = opts.slice(0, TOPN);
const chips = [``]
.concat(shown.map((k) => {
const label = k.length > 16 ? k.slice(0, 15) + '…' : k;
return ``;
})).join('');
const more = opts.length > TOPN
? `共 ${opts.length} 个选项,已显示成交额最高的 ${TOPN} 个;可用搜索框精确查找`
: `共 ${opts.length} 个选项`;
valueRow = `${
escapeHtml((MH_LEDGER_DIMS.find((d) => d.id === mhUi.ledgerDim) || {}).label || '')
}${chips}${more}
`;
}
return `
大类${typeBtn('all', '全部')}${typeBtn('sold', '卖出')}${typeBtn('bought', '买入')}
维度${dimBtn('all', '不限')}${dimBtn('game', '按游戏')}${dimBtn('item', '按物品')}${dimBtn('month', '按月份')}
点击表头排序
${valueRow}
${pager}`;
}, (bodyEl) => {
const bar = bodyEl.querySelector('.sme-mh-toolbar');
const q = bodyEl.querySelector('#sme-mh-ledger-q');
const onClick = (e) => {
const chip = e.target.closest('button.sme-mh-chip');
const th = e.target.closest('th.sme-mh-th-sort');
if (chip) {
if (chip.dataset.ltype) { mhUi.ledgerType = chip.dataset.ltype; mhUi.ledgerValue = ''; mhUi.ledgerPage = 1; mhShowPanel('ledger'); }
else if (chip.dataset.ldim) { mhUi.ledgerDim = chip.dataset.ldim; mhUi.ledgerValue = ''; mhUi.ledgerPage = 1; mhShowPanel('ledger'); }
else if (chip.dataset.lvalue !== undefined) { mhUi.ledgerValue = chip.dataset.lvalue; mhUi.ledgerPage = 1; mhShowPanel('ledger'); }
else if (chip.dataset.page) {
const d = chip.dataset.page;
if (d === 'prev' && mhUi.ledgerPage > 1) mhUi.ledgerPage--;
else if (d === 'next') mhUi.ledgerPage++;
mhShowPanel('ledger');
} else if (chip.id === 'sme-mh-ledger-reset') {
mhUi.ledgerType = 'all'; mhUi.ledgerDim = 'all';
mhUi.ledgerValue = ''; mhUi.ledgerQuery = ''; mhUi.ledgerPage = 1;
mhShowPanel('ledger');
}
return;
}
if (th) {
const k = th.dataset.sort;
if (mhUi.ledgerSort === k) mhUi.ledgerDesc = !mhUi.ledgerDesc;
else { mhUi.ledgerSort = k; mhUi.ledgerDesc = k !== 'name'; }
mhShowPanel('ledger');
}
};
bar.addEventListener('click', onClick);
bodyEl.addEventListener('click', onClick);
let timer = null;
const onInput = () => {
clearTimeout(timer);
timer = setTimeout(() => {
mhUi.ledgerQuery = q.value;
mhUi.ledgerPage = 1;
mhShowPanel('ledger');
}, 260);
};
q.addEventListener('input', onInput);
bodyEl._cleanup = () => {
clearTimeout(timer);
bar.removeEventListener('click', onClick);
bodyEl.removeEventListener('click', onClick);
q.removeEventListener('input', onInput);
};
});
}
// ---------- 面板 6:导出 ----------
const MH_EXPORT_FORMATS = [
{ id: 'csv', label: 'CSV', desc: '逗号分隔 · 带 UTF-8 BOM · Excel 双击直开' },
{ id: 'tsv', label: 'TSV', desc: '制表符分隔 · 字段内换行自动转义' },
{ id: 'json', label: 'JSON', desc: '结构化 · 含汇总/物品/游戏/月份/每日/价格分档' },
{ id: 'md', label: 'Markdown', desc: '可读报告 · 统计卡 + 榜单 + 月度表 + 明细' },
];
function mhPaneExport(s, money, note) {
const fmt = mhUi.exportFormat || mhState.format || 'csv';
const filtered = mhFilteredRows();
const hasFilter = mhUi.ledgerType !== 'all'
|| (mhUi.ledgerDim !== 'all' && !!mhUi.ledgerValue)
|| !!mhUi.ledgerQuery.trim();
return mhRenderPane('export', note, () => {
const cards = MH_EXPORT_FORMATS.map((f) =>
``).join('');
const scopeRows = hasFilter ? filtered : mhState.rows;
const preview = fmt === 'json'
? ['{', ' "meta": { "recordCount": ' + scopeRows.length + ', "currency": "' + mhCurrencyCode() + '" },',
' "summary": { "total": ' + s.count + ', "netProfit": ' + (Math.round(s.netCents) / 100).toFixed(2) + ' },',
' "transactions": [ … ' + scopeRows.length + ' 条 … ]', '}'].join('\n')
: fmt === 'md'
? '# Steam 市场交易记录汇总\n\n## 总体\n\n| 指标 | 数值 |\n| --- | --- |\n| 记录总数 | ' + s.count + ' |\n…'
: '序号,事件ID,挂单ID,物品名称,游戏,AppID,类型,状态,价格原文,价格,币种,…\n'
+ '1,' + escapeHtml(scopeRows.length ? scopeRows[0].eventId : '') + ',' + escapeHtml(scopeRows.length ? scopeRows[0].listingId : '') + ','
+ escapeHtml(scopeRows.length ? scopeRows[0].name : '') + ',…';
return `
格式${cards}
预览(前几行)
${escapeHtml(preview)}
本次导出
格式
${escapeHtml(fmt.toUpperCase())}
币种
${escapeHtml(mhCurrencyCode())}
导出会包含全部字段;JSON 与 Markdown 另附多维汇总。时间戳年份为推断值。
`;
}, (bodyEl) => {
const bar = bodyEl.querySelector('.sme-mh-toolbar');
let scope = hasFilter ? 'filtered' : 'all';
const onBar = (e) => {
const chip = e.target.closest('button.sme-mh-fmt, button.sme-mh-chip');
if (!chip) return;
if (chip.dataset.fmt) { mhUi.exportFormat = chip.dataset.fmt; mhShowPanel('export'); }
else if (chip.dataset.scope === 'all') { scope = 'all'; mhShowPanel('export'); }
else if (chip.dataset.scope === 'filtered') { scope = 'filtered'; mhShowPanel('export'); }
};
bar.addEventListener('click', onBar);
const doBtn = bodyEl.querySelector('#sme-mh-do-export');
const onExport = () => {
const rows = scope === 'filtered' ? mhFilteredRows() : mhState.rows;
if (!rows.length) { toast('没有可导出的记录', 'warning'); return; }
mhRunExport({ format: fmt, rows, label: scope === 'filtered' ? 'filtered' : '' });
};
doBtn.addEventListener('click', onExport);
bodyEl._cleanup = () => {
bar.removeEventListener('click', onBar);
doBtn.removeEventListener('click', onExport);
};
});
}
// ---------- 工具条 ----------
function mhSetStatus(msg, cls) {
const el = document.getElementById('sme-mh-status');
if (!el) return;
el.textContent = msg || '';
el.className = 'sme-mh-status' + (cls === 'ok' ? ' sme-ok' : (cls === 'err' ? ' sme-err' : ''));
}
function mhSetProgress(ratio, show) {
const box = document.getElementById('sme-mh-progress');
const bar = document.getElementById('sme-mh-progress-bar');
if (box) box.style.display = show ? '' : 'none';
if (bar) bar.style.width = Math.max(0, Math.min(1, ratio || 0)) * 100 + '%';
}
function mhSetBusy(busy) {
const panel = document.getElementById('sme-mh-panel');
if (!panel) return;
panel.querySelectorAll('button').forEach(b => {
if (b.id === 'sme-mh-stop') {
b.style.display = busy ? '' : 'none';
b.disabled = !busy;
} else {
b.disabled = !!busy;
}
});
}
function mhCooldownLeft() {
return (mhState.paused && mhState.nextAt) ? Math.max(0, Math.ceil((mhState.nextAt - Date.now()) / 1000)) : 0;
}
function mhFmtCountdown(sec) {
return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
}
/** 限流冷却期间每秒刷新倒计时文案,到点自动解锁按钮 */
let _mhCoolTimer = null;
function mhStartCooldownWatch() {
if (_mhCoolTimer) return;
_mhCoolTimer = setInterval(() => {
if (mhState.fetching || !mhState.paused) { mhStopCooldownWatch(); return; }
const left = mhCooldownLeft();
if (left <= 0) {
mhState.paused = false;
mhState.nextAt = 0;
mhState.coolStreak = 0;
mhStopCooldownWatch();
mhSetStatus(`冷却结束,可从第 ${mhState.rows.length + 1} 条继续索引`, 'ok');
mhRefreshStatus();
return;
}
mhSetStatus(`限流冷却中 ${mhFmtCountdown(left)} · 已保留 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条`, 'err');
}, 1000);
}
function mhStopCooldownWatch() {
if (_mhCoolTimer) { clearInterval(_mhCoolTimer); _mhCoolTimer = null; }
}
function mhRefreshStatus() {
if (mhState.fetching) {
mhSetProgress(mhState.totalCount ? mhState.rows.length / mhState.totalCount : 0, true);
return;
}
if (mhState.rows.length === 0 && !mhState.paused) {
mhSetProgress(0, false);
mhSetStatus('尚未索引交易记录');
mhStopCooldownWatch();
return;
}
if (mhState.paused && mhCooldownLeft() > 0) {
mhSetProgress(mhState.totalCount ? mhState.rows.length / mhState.totalCount : 0, false);
mhSetStatus(`限流冷却中 ${mhFmtCountdown(mhCooldownLeft())} · 已保留 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条`, 'err');
mhStartCooldownWatch();
return;
}
mhSetProgress(0, false);
mhStopCooldownWatch();
const when = mhState.fetchedAt
? new Date(mhState.fetchedAt).toLocaleString('zh-CN', { hour12: false }) : '';
if (mhState.rows.length === 0) { mhSetStatus('尚未索引交易记录'); return; }
// v1.6.8:不再重复报同一个数字。完成 / 未完成两套文案。
const text = mhState.complete
? `已索引全部 ${mhState.rows.length} 条`
: `已索引 ${mhState.rows.length} / ${mhState.totalCount || '?'} 条 · 可继续`;
mhSetStatus(`${text}${when ? ' · ' + when : ''}`, mhState.complete ? 'ok' : '');
mhSyncFetchButton();
}
/** 刷新/继续/重建按钮的语义随索引状态变化,避免误清缓存 */
function mhFetchButtonMeta() {
if (mhState.complete) return { label: '重新索引', title: '已索引完整。点击将清空并从头重新抓取' };
if (mhState.rows.length > 0) return { label: '继续', title: `从第 ${mhState.rows.length + 1} 条继续索引(保留已抓取部分)` };
return { label: '索引', title: '开始抓取全量交易记录' };
}
function mhUpdateFetchButton(btn) {
if (!btn) return;
const meta = mhFetchButtonMeta();
const span = btn.querySelector('span');
if (span) span.textContent = meta.label;
btn.title = meta.title;
btn.setAttribute('aria-label', meta.title);
}
/** 索引状态变化后刷新按钮文案与冷却计时 */
function mhSyncFetchButton() {
mhUpdateFetchButton(document.getElementById('sme-mh-refresh-btn'));
}
function injectMhPanel() {
const tabs = document.getElementById('myMarketTabs');
if (!tabs) return false;
if (mhState.panelEl && document.body.contains(mhState.panelEl)) return true;
const formatSel = h('select', {
id: 'sme-mh-format', class: 'sme-mh-format', 'aria-label': '导出格式',
onChange: (e) => {
mhState.format = ['csv', 'tsv', 'json', 'md'].includes(e.target.value) ? e.target.value : 'csv';
settings.set('marketHistoryFormat', mhState.format);
toast(`导出格式已切换为 ${mhState.format.toUpperCase()}`, 'info');
},
html: ''
+ '',
});
const savedFmt = settings.get('marketHistoryFormat');
formatSel.value = MH_EXPORT_FORMATS.some((f) => f.id === savedFmt) ? savedFmt : 'csv';
mhState.format = formatSel.value;
const actions = h('span', { class: 'sme-mh-actions' }, [
h('button', {
id: 'sme-mh-summary-btn', class: 'sme-market-btn', type: 'button',
html: ICONS.chart + '汇总',
title: '查看市场交易记录分析(概览 / 趋势 / 分布 / 日历 / 明细 / 导出)',
onClick: async () => { if (await mhEnsureData('查看分析')) openMhAnalysis(); },
}),
formatSel,
h('button', {
id: 'sme-mh-export-btn', class: 'sme-market-btn', type: 'button',
html: ICONS.download + '导出',
title: '按所选格式导出全部交易记录',
onClick: async () => { if (await mhEnsureData('导出')) mhRunExport(); },
}),
h('button', {
id: 'sme-mh-refresh-btn', class: 'sme-market-btn', type: 'button',
html: ICONS.refreshCw + '',
title: '',
onClick: async (e) => {
if (mhState.fetching) return;
const btn = e.currentTarget;
const meta = mhFetchButtonMeta();
if (meta.force) {
// 只有「已完整索引」时才允许从零重来,且二次确认
const ok = await showConfirmDialog(
'重新索引会清空当前已抓取的 '
+ `${mhState.rows.length} 条记录并从第 1 条重抓。
`
+ ''
+ '如果只是没抓完,请用「继续」从断点接着抓。',
{ title: '重新索引', okText: '清空并重来', okClass: 'sme-dialog-ok' });
if (!ok) return;
mhState.rows = [];
mhState.totalCount = 0;
mhState.fetchedAt = 0;
mhState.nextOffset = 0;
mhState.complete = false;
mhState.paused = false;
mhState.nextAt = 0;
mhState.coolStreak = 0;
mhSetStatus('正在重新索引…');
await mhFetchAll({ force: true });
} else {
// 断点续传:保留已有数据
mhSetStatus(`正在从第 ${mhState.rows.length + 1} 条继续索引…`);
await mhFetchAll();
}
mhUpdateFetchButton(btn);
},
}),
h('button', {
id: 'sme-mh-stop', class: 'sme-market-btn sme-danger', type: 'button',
text: '停止', style: { display: 'none' },
onClick: () => { mhState.abort = true; mhSetStatus('正在停止…'); },
}),
]);
// 按当前索引状态设定按钮文案(索引 / 继续 / 重新索引)
const fetchBtn = actions.querySelector('#sme-mh-refresh-btn');
if (fetchBtn) mhUpdateFetchButton(fetchBtn);
// 独立容器,作为 #myMarketTabs 的前一个兄弟节点插入。
// Tab 行内部依赖 float 布局("出售物品"右浮动),同层插入必然重叠;
// 放到上一行的块级容器里可完全绕开,且不改动 Steam 原有样式。
const panel = h('div', { class: 'sme-mh-panel', id: 'sme-mh-panel' }, [
h('div', { class: 'sme-mh-panel-inner' }, [
h('span', { class: 'sme-mh-title', html: ICONS.chart + '交易记录' }),
h('span', { class: 'sme-mh-status', id: 'sme-mh-status' }),
actions,
]),
h('div', { class: 'sme-mh-progress', id: 'sme-mh-progress' }, [
h('div', { class: 'sme-mh-progress-bar', id: 'sme-mh-progress-bar' }),
]),
]);
mhState.panelEl = panel;
const host = tabs.parentNode || tabs;
host.insertBefore(panel, tabs);
mhRefreshStatus();
return true;
}
function initMarketHistoryPanel() {
if (!settings.get('marketHistoryPanel')) return;
if (detectPageType() !== 'browse') return;
if (!mhState.styleAdded) {
GM_addStyle(MH_CSS);
mhState.styleAdded = true;
}
if (mhState.active) return;
// #myMarketTabs 由页面脚本异步渲染,轮询等待
let attempts = 0;
const tryStart = () => {
if (injectMhPanel()) {
mhState.active = true;
mhLoadCache();
mhRefreshStatus();
L('Market history panel active, cached rows:', mhState.rows.length);
return;
}
if (attempts++ < 30) setTimeout(tryStart, 500);
};
tryStart();
}
function cleanupMarketHistoryPanel() {
// v1.6.8:切页面前先落盘再中断。
// 原实现直接 abort = true 却不保存,SPA 导航切走时最多丢掉 400 条进度;
// 现在确保断点持久化,切回来点「继续」即可接着抓。
if (mhState.fetching && mhState.rows.length) {
mhSaveCache(mhState.rows, mhState.totalCount, mhState.nextOffset);
}
if (mhState.panelEl) { mhState.panelEl.remove(); mhState.panelEl = null; }
// 分析窗口挂在 body 上,需单独销毁并解绑全局 keydown
if (_mhShell) {
if (_mhShell.modal._dispose) _mhShell.modal._dispose();
const bodies = _mhShell.panelsEl.querySelectorAll('.sme-mh-pbody');
bodies.forEach((b) => { if (b._cleanup) { try { b._cleanup(); } catch (e) {} b._cleanup = null; } });
_mhShell.modal.remove();
_mhShell = null;
}
mhStopCooldownWatch();
mhState.active = false;
mhState.abort = true;
}
// ==================== v1.6.4 市场页 Beta 公告条隐藏 ====================
// 社区市场首页顶部有一条官方公告:
// 「我们目前正在对物品页面和高级搜索等市场功能进行 Beta 测试…」
// 该条由服务端渲染在 #marketBetaNoteCtn 内,且市场页是 SPA 导航会重新挂载,
// 因此用一条 CSS 规则常驻压制——比逐次 JS 移除更稳,SPA 切回来也不会复现。
const MARKET_BETA_HIDE_CSS = `
#marketBetaNoteCtn{display:none!important}
.marketBetaNoteText{display:none!important}
#marketBetaNote{display:none!important}
`;
let _betaNoteStyleAdded = false;
function applyMarketBetaNoteHidden() {
if (!settings.get('hideMarketBetaNote')) return;
if (_betaNoteStyleAdded) return;
GM_addStyle(MARKET_BETA_HIDE_CSS);
_betaNoteStyleAdded = true;
}
// ==================== 初始化 ====================
let initialized = false;
let lastURL = '';
let urlCheckTimer = null;
function init() {
if (initialized) return;
initialized = true;
L('Initializing on', location.href);
const sid = getSteamId();
if (sid) loadCacheForSession(sid);
else W('Steam ID not detected, some features may not work');
const pageType = detectPageType();
L('Page type:', pageType);
if (pageType === 'listing') {
initListingPage();
} else if (pageType === 'browse') {
initBrowsePage();
} else if (pageType === 'inventory') {
initInventoryPage();
} else if (pageType === 'multisell') {
initMultisellPage();
} else if (pageType === 'tradeoffer') {
initTradeOfferPage();
} else if (pageType === 'booster') {
initBoosterPage();
}
// URL 变化检测(Steam 市场使用 AJAX 导航)
lastURL = location.href;
urlCheckTimer = setInterval(() => {
if (location.href !== lastURL) {
lastURL = location.href;
handleURLChange();
}
}, 800);
}
function initListingPage() {
listingInfo = getListingInfo();
L('Listing info:', listingInfo);
// 详情页已拥有物品标识
if (settings.get('ownedBadges')) {
setTimeout(processDetailPageBadge, 600);
}
// 浮窗模式先放出按钮。经典商品页没有 SSR,原先要空等约 10 秒才会插入顶部面板。
if (shouldUseFloatPanel()) ensureAnalysisFab();
// 等待 SSR 数据可用
const pageHref = location.href;
const tryExtract = (attempt) => {
if (location.href !== pageHref) return;
const ssrData = extractSSRData();
if (ssrData.sellOrders !== null) {
const hadSummary = !!tabPanes.summary;
allSellOrders = ssrData.sellOrders;
// 设置全局 classid,并为缺少 classid 的订单补充
if (ssrData.classid) _globalClassid = ssrData.classid;
if (_globalClassid) {
for (const o of allSellOrders) {
if (!o.classid) o.classid = _globalClassid;
}
}
L(`Found ${allSellOrders.length} sell orders`);
presentListingPanel();
if (hadSummary) onListingOrdersReady();
if (settings.get('autoLoadHistory')) {
// 预加载价格历史
if (listingInfo) {
fetchPriceHistory(listingInfo.appid, listingInfo.marketHashName)
.then(data => { priceHistoryData = data; })
.catch(() => {});
}
}
} else if (attempt < 20) {
setTimeout(() => tryExtract(attempt + 1), 500);
} else {
L('No sell orders found after timeout, presenting panel anyway');
presentListingPanel();
}
};
tryExtract(0);
// v1.4.6:我的挂单市场顺序跟踪(延迟等待 SSR renderContext 与 React 挂载)
setTimeout(initMarketRankTracker, 800);
// v1.4.9:参考货币换算价格注入 + 监听 DOM 变化自动补注
startRefPriceInjection();
}
function initBrowsePage() {
if (settings.get('ownedBadges')) {
setTimeout(processAllBrowseRows, 400);
setupBrowseObserver();
}
createFab();
initMyListingsEnhancement();
// v1.6.0:市场页"我的市场历史记录"工具条(汇总 / 导出 / 格式下拉)
initMarketHistoryPanel();
// v1.6.4:隐藏 Steam 官方的市场 Beta 测试公告条
applyMarketBetaNoteHidden();
// v1.5.7:/market/search(pageType=browse)此前完全没有注入入口,
// 「起价:₹xx」卡片换算一直不出现;这里与详情页共用同一入口。
startRefPriceInjection();
}
function handleURLChange() {
L('URL changed to', location.href);
const pageType = detectPageType();
// 清理旧面板(浮窗按钮挂在 body 上,要单独摘掉)
removeAnalysisFloat();
if (panelEl) { panelEl.remove(); panelEl = null; }
if (fabEl) { fabEl.remove(); fabEl = null; }
if (fabPopup) { fabPopup.remove(); fabPopup = null; }
if (browseObserver) { browseObserver.disconnect(); browseObserver = null; }
if (invObserver) { invObserver.disconnect(); invObserver = null; }
if (invToolbarEl) { invToolbarEl.remove(); invToolbarEl = null; }
if (invPriceLabels) { invPriceLabels.clear(); }
if (invPriceIO) { invPriceIO.disconnect(); invPriceIO = null; } // v1.5.1 P2-B:断开按需标注观察器
if (tradeObserver) { tradeObserver.disconnect(); tradeObserver = null; }
cleanupBoosterPage();
cleanupMyListingsEnhancement();
cleanupMarketRankTracker();
cleanupMarketHistoryPanel();
// v1.4.9:清理参考货币价格标签和观察器
cleanupRefPriceObserver();
allSellOrders = [];
priceHistoryData = null;
orderBookData = null;
currentTab = 'summary';
priceCheckResults = null;
priceCheckLoading = false;
if (pageType === 'listing') {
// v1.5.0:SPA 导航到物品页时补注入假 hook(完整页面加载已在 document-start 注入过,此处幂等)
if (settings.get('showMorePrices')) installShowMoreHook();
initListingPage();
} else if (pageType === 'browse') {
initBrowsePage();
} else if (pageType === 'inventory') {
initInventoryPage();
} else if (pageType === 'multisell') {
initMultisellPage();
} else if (pageType === 'tradeoffer') {
initTradeOfferPage();
} else if (pageType === 'booster') {
initBoosterPage();
}
}
// ==================== 补充包制作器页面 ====================
// ---- 补充包页面状态 ----
let boosterPanelEl = null;
let boosterGameInfo = null; // CBoosterCreatorPage.sm_rgBoosterData
let boosterConfig = null; // 用户配置 {queue:[], favorites:[], blacklist:[], pageSize, filter}
let boosterPriceData = {}; // {appid: {cardPrice, boosterBuyOrder, hadCard, hadBooster, sold}}
let boosterGemsSackPrice = 0; // 市场一袋宝珠价格(分)
let boosterCustomSackPrice = 0; // 自定义一袋宝珠价格(分)
let boosterPageNum = 1;
let boosterPageSize = 15;
let boosterSearchKeyword = '';
let boosterFilterType = 'all'; // all|queue|favorites|blacklist|available
let boosterSortKey = 'name';
let boosterSortDir = 'asc';
let boosterQueryRunning = false;
let boosterCreateRunning = false;
let boosterDoneList = [];
let boosterGemsDisplay = 0; // 当前用户宝石数量
// 卡牌数量 → 宝石成本映射
const CARD_COUNT_TO_GEMS = { 5:1200, 6:1000, 7:857, 8:750, 9:667, 10:600, 11:545, 12:500, 13:462, 14:429, 15:400 };
/** 加载补充包配置 */
function loadBoosterConfig() {
try {
const raw = localStorage.getItem('sme_booster_config');
if (raw) {
const cfg = JSON.parse(raw);
boosterConfig = {
queue: cfg.queue || [],
favorites: cfg.favorites || [],
blacklist: cfg.blacklist || [],
pageSize: cfg.pageSize || 15,
filter: cfg.filter !== false,
customSackPrice: cfg.customSackPrice || 0,
autoQueryBooster: cfg.autoQueryBooster || false,
};
}
} catch (e) {}
if (!boosterConfig) {
boosterConfig = { queue: [], favorites: [], blacklist: [], pageSize: 15, filter: true, customSackPrice: 0, autoQueryBooster: false };
}
boosterPageSize = boosterConfig.pageSize;
boosterCustomSackPrice = boosterConfig.customSackPrice || 0;
}
/** 保存补充包配置 */
function saveBoosterConfig() {
try { localStorage.setItem('sme_booster_config', JSON.stringify(boosterConfig)); } catch (e) {}
}
/** 加载补充包价格缓存 */
function loadBoosterPriceCache() {
try {
const raw = localStorage.getItem('sme_booster_price_cache');
if (raw) boosterPriceData = JSON.parse(raw) || {};
} catch (e) {}
}
/** 保存补充包价格缓存 */
function saveBoosterPriceCache() {
try { localStorage.setItem('sme_booster_price_cache', JSON.stringify(boosterPriceData)); } catch (e) {}
}
/** 补充包页面 toast */
let _boosterToastEl = null;
function boosterToast(msg, type = 'info') {
if (!_boosterToastEl) {
_boosterToastEl = h('div', { class: 'sme-booster-toast' });
document.body.appendChild(_boosterToastEl);
}
_boosterToastEl.textContent = msg;
_boosterToastEl.className = 'sme-booster-toast sme-show sme-' + type;
clearTimeout(_boosterToastEl._timer);
_boosterToastEl._timer = setTimeout(() => {
_boosterToastEl.classList.remove('sme-show');
}, 3000);
}
/** 获取一袋宝珠市场最低售价 */
async function fetchGemsSackPrice() {
try {
// 尝试从缓存获取 item_nameid
let sackItemId = '';
const cacheRaw = localStorage.getItem('sme_booster_sack_cache');
if (cacheRaw) {
try { sackItemId = JSON.parse(cacheRaw).itemId || ''; } catch (e) {}
}
if (!sackItemId) {
// 从市场页面获取 item_nameid
const html = (await smeRequest('https://steamcommunity.com/market/listings/753/753-Sack%20of%20Gems', { expect: 'text', onNonOk: 'null' })) || '';
const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/);
if (m) {
sackItemId = m[1];
localStorage.setItem('sme_booster_sack_cache', JSON.stringify({ itemId: sackItemId, ts: Date.now() }));
}
}
if (!sackItemId) return 0;
const wi = getWalletInfo();
const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${sackItemId}&two_factor=0`;
const data = await doFetch(url);
if (data && data.sell_order_graph && data.sell_order_graph.length > 0) {
return parseInt(data.sell_order_graph[0][0], 10) || 0;
}
} catch (e) {
W('Failed to fetch gems sack price:', e.message);
}
return 0;
}
/** 获取补充包市场最低售价和最高买单 */
async function fetchBoosterMarketPrice(appid, name) {
try {
// 使用英文名称构建 market hash,避免中文名导致链接失效
const englishName = await resolveBoosterEnglishName(appid, name);
const marketHash = `${appid}-${englishName} Booster Pack`;
// 先获取 item_nameid
let boosterItemId = '';
const cacheKey = `booster_itemid_${appid}`;
const cacheRaw = localStorage.getItem('sme_booster_item_cache');
if (cacheRaw) {
try {
const cache = JSON.parse(cacheRaw);
boosterItemId = (cache[appid] || {}).itemId || '';
} catch (e) {}
}
if (!boosterItemId) {
const boosterUrl = `https://steamcommunity.com/market/listings/753/${encodeURIComponent(marketHash.replace(/\//g, '-'))}`;
const html = (await smeRequest(boosterUrl, { expect: 'text', onNonOk: 'null' })) || '';
const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/);
if (m) {
boosterItemId = m[1];
const cache = cacheRaw ? JSON.parse(cacheRaw) : {};
cache[appid] = { itemId: boosterItemId, ts: Date.now() };
localStorage.setItem('sme_booster_item_cache', JSON.stringify(cache));
}
}
if (!boosterItemId) return null;
const wi = getWalletInfo();
const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${boosterItemId}&two_factor=0`;
const data = await doFetch(url);
const result = { buyOrder: 0, sellOrder: 0 };
if (data.buy_order_graph && data.buy_order_graph.length > 0) {
result.buyOrder = parseInt(data.buy_order_graph[0][0], 10) || 0;
}
if (data.sell_order_graph && data.sell_order_graph.length > 0) {
result.sellOrder = parseInt(data.sell_order_graph[0][0], 10) || 0;
}
return result;
} catch (e) {
W(`Failed to fetch booster price for ${appid}:`, e.message);
return null;
}
}
/** 查询单个游戏的卡牌均价 */
async function fetchCardPrice(appid) {
try {
// 搜索该游戏的普通卡牌
const searchUrl = `https://steamcommunity.com/market/search/render/?start=0&count=20&category_753_cardborder[]=tag_cardborder_0&appid=753&category_753_Game[]=tag_app_${appid}&norender=1`;
const searchData = await doFetch(searchUrl);
if (!searchData || !searchData.total_count) return null;
const cardCount = searchData.total_count;
let totalPrice = 0;
let successCount = 0;
// 从搜索结果获取每个卡牌的 market_hash_name
const cardNames = [];
if (searchData.results && Array.isArray(searchData.results)) {
for (const r of searchData.results) {
if (r.hash_name) cardNames.push(r.hash_name);
}
}
// 逐个查询卡牌最低售价
for (const hashName of cardNames) {
try {
// 获取卡牌详情页的 item_nameid
let cardItemId = '';
const cardCacheRaw = localStorage.getItem('sme_booster_card_item_cache');
if (cardCacheRaw) {
try {
const cc = JSON.parse(cardCacheRaw);
cardItemId = (cc[hashName] || '').itemId || '';
} catch (e) {}
}
if (!cardItemId) {
const cardUrl = `https://steamcommunity.com/market/listings/753/${encodeURIComponent(hashName)}`;
const html = (await smeRequest(cardUrl, { expect: 'text', onNonOk: 'null' })) || '';
const m = html.match(/Market_LoadOrderSpread\(\s*(\d+)/);
if (m) {
cardItemId = m[1];
const cc = cardCacheRaw ? JSON.parse(cardCacheRaw) : {};
cc[hashName] = { itemId: cardItemId, ts: Date.now() };
localStorage.setItem('sme_booster_card_item_cache', JSON.stringify(cc));
}
}
if (cardItemId) {
const wi = getWalletInfo();
const url = `https://steamcommunity.com/market/itemordershistogram?country=${wi.country || 'US'}&language=${wi.language || 'english'}¤cy=${wi.wallet_currency || 1}&item_nameid=${cardItemId}&two_factor=0`;
const data = await doFetch(url);
if (data.sell_order_graph && data.sell_order_graph.length > 0) {
totalPrice += parseInt(data.sell_order_graph[0][0], 10) || 0;
successCount++;
}
}
// 请求间隔,避免限流
await new Promise(r => setTimeout(r, 600));
} catch (e) {
W(`Failed to fetch card price for ${hashName}:`, e.message);
}
}
if (successCount > 0) {
// 三张卡牌均价 × 税后(× 0.87)
const avgCardPrice = totalPrice / successCount;
const totalCardValue = avgCardPrice * 3; // 补充包含3张卡牌
return { cardPrice: totalCardValue, cardCount, successCount };
}
return null;
} catch (e) {
W(`Failed to fetch card prices for ${appid}:`, e.message);
return null;
}
}
/** 制作补充包 */
async function createBoosterPack(appid, series) {
try {
const sessionid = await resolveSessionId();
if (!sessionid) throw new Error('无法获取 sessionid');
const body = new URLSearchParams({
sessionid,
appid: String(appid),
series: String(series),
tradability_preference: '1',
});
const json = await smeRequest('https://steamcommunity.com/tradingcards/ajaxcreatebooster/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.toString(),
});
if (!json || !json.success) throw new Error((json && json.message) || '制作失败');
return json;
} catch (e) {
W(`Failed to create booster for ${appid}:`, e.message);
throw e;
}
}
/** 获取用户当前宝石数量 */
function getBoosterGems() {
const el = document.querySelector('.goovalue');
if (el) {
const text = el.textContent.replace(/[,,\s]/g, '');
const num = parseInt(text, 10);
if (!isNaN(num)) return num;
}
return 0;
}
// ---- 英文名称缓存(用于补充包链接生成) ----
let boosterEnglishNameCache = {}; // { appid: 'English Name' }
let _boosterEnNameFetching = new Set(); // 正在获取中的 appid,避免重复请求
/** 加载英文名称缓存 */
function loadBoosterEnglishNameCache() {
try {
const raw = localStorage.getItem('sme_booster_en_name_cache');
if (raw) boosterEnglishNameCache = JSON.parse(raw) || {};
} catch (e) {}
}
/** 保存英文名称缓存 */
function saveBoosterEnglishNameCache() {
try { localStorage.setItem('sme_booster_en_name_cache', JSON.stringify(boosterEnglishNameCache)); } catch (e) {}
}
/** 去除书名号及特殊符号 */
function pureName(str) {
return (str || '').replace(/[《》〈〉「」『』™©®]/g, '');
}
/** 异步获取游戏英文名称(带缓存),参考 Steam_Show_English_Name.js 逻辑 */
async function fetchEnglishName(appid) {
const id = String(appid);
if (!id || id === '0') return null;
// 内存缓存
if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id];
// 防止并发重复请求
if (_boosterEnNameFetching.has(id)) return null;
_boosterEnNameFetching.add(id);
try {
const url = `https://store.steampowered.com/api/appdetails?appids=${id}&l=english`;
const data = await gmFetchJson(url, 12000);
if (data && data[id] && data[id].success === true && data[id].data && data[id].data.name) {
const enName = pureName(data[id].data.name);
boosterEnglishNameCache[id] = enName;
saveBoosterEnglishNameCache();
return enName;
}
} catch (e) {
W(`Failed to fetch English name for appid ${id}:`, e.message);
} finally {
_boosterEnNameFetching.delete(id);
}
return null;
}
/** 同步获取链接用名称:优先英文缓存,否则回退到去符号的原始名 */
function getBoosterLinkName(appid, fallbackName) {
const id = String(appid);
if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id];
return pureName(fallbackName || '');
}
/** 异步解析英文名称:缓存命中直接返回,否则请求 API 后返回 */
async function resolveBoosterEnglishName(appid, fallbackName) {
const id = String(appid);
if (boosterEnglishNameCache[id]) return boosterEnglishNameCache[id];
const en = await fetchEnglishName(id);
return en || pureName(fallbackName || '');
}
/** 构建补充包市场链接 */
function buildBoosterMarketUrl(appid, name) {
const linkName = getBoosterLinkName(appid, name);
const safeName = linkName.replace(/\//g, '-');
return `https://steamcommunity.com/market/listings/753/${appid}-${encodeURIComponent(safeName)}%20Booster%20Pack`;
}
/** 构建卡牌市场搜索链接 */
function buildCardMarketUrl(appid) {
return `https://steamcommunity.com/market/search?category_Game=app_${appid}&appid=753`;
}
/** 获取游戏可用状态 */
function getGameStatus(appid) {
const selector = document.getElementById('booster_game_selector');
if (!selector) return 'unknown';
const option = selector.querySelector(`option[value="${appid}"]`);
if (!option) return 'unknown';
if (option.classList.contains('available')) return 'available';
if (boosterDoneList.includes(String(appid))) return 'done';
return 'cooldown';
}
/** 获取游戏宝石成本 */
function getGameGemsCost(appid) {
if (!boosterGameInfo) return 0;
const info = boosterGameInfo[appid];
if (!info) return 0;
return parseInt(info.price, 10) || 0;
}
/** 计算利润 */
function calcBoosterProfit(appid) {
const gemsCost = getGameGemsCost(appid);
if (gemsCost === 0) return null;
// 一袋宝珠1000 gems的市场价 → 每gem价值
const sackPrice = boosterCustomSackPrice > 0 ? boosterCustomSackPrice : boosterGemsSackPrice;
if (sackPrice === 0) return null;
const gemCostCents = Math.round(sackPrice * gemsCost / 1000);
// 卡牌收益(税后)
const pd = boosterPriceData[appid];
const cardRevenue = pd && pd.cardPrice ? Math.round(pd.cardPrice * 0.87) : 0;
// 补充包买单收益
const boosterBuyRevenue = pd && pd.boosterBuyOrder ? pd.boosterBuyOrder : 0;
// 取较高者作为预期收益
const expectedRevenue = Math.max(cardRevenue, boosterBuyRevenue);
return { cost: gemCostCents, revenue: expectedRevenue, profit: expectedRevenue - gemCostCents };
}
/** 格式化价格(分→元) */
function formatBoosterPrice(cents) {
if (cents == null || isNaN(cents)) return '--';
const wi = getWalletInfo();
const symbol = (wi.currency_code && wi.currency_code in CURRENCY_ID_TO_CODE) ? '' : '';
return (cents / 100).toFixed(2);
}
/** 封面图降级链(参考 sglv-cover-fallback.lib.js) */
const COVER_CDN_AKAMAI = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/';
const COVER_CDN_CF = 'https://cdn.cloudflare.steamstatic.com/steam/apps/';
const COVER_CHAIN = ['capsule_231x87.jpg', 'capsule_184x69.jpg', 'capsule_467x181.jpg', 'header.jpg'];
const COVER_PLACEHOLDER = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 184 69'%3E%3Crect fill='%231a1f3a' width='184' height='69'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%2364748b' font-family='system-ui' font-size='10'%3ENo Image%3C/text%3E%3C/svg%3E";
const _coverCache = new Map();
function getBoosterCoverUrl(appId) {
const id = String(appId);
if (_coverCache.has(id)) return _coverCache.get(id);
try {
const cached = localStorage.getItem('sme_cover_' + id);
if (cached) { _coverCache.set(id, cached); return cached; }
} catch(e) {}
return COVER_CDN_AKAMAI + id + '/' + COVER_CHAIN[0];
}
function buildCoverFallbackChain(appId) {
const id = String(appId);
const chain = [];
COVER_CHAIN.forEach(p => chain.push(COVER_CDN_AKAMAI + id + '/' + p));
chain.push(COVER_CDN_CF + id + '/capsule_231x87.jpg');
chain.push(COVER_CDN_CF + id + '/header.jpg');
return chain;
}
function handleCoverError(img) {
const appId = img.dataset.appid;
if (!appId) return;
const chain = buildCoverFallbackChain(appId);
let idx = parseInt(img.dataset.fbIdx || '0', 10);
while (idx < chain.length && (img.src === chain[idx] || img.src === decodeURIComponent(chain[idx]))) idx++;
if (idx < chain.length) {
img.dataset.fbIdx = String(idx + 1);
img.src = chain[idx];
} else {
img.src = COVER_PLACEHOLDER;
img.classList.add('sme-booster-cover-failed');
}
}
function handleCoverLoad(img) {
const appId = img.dataset.appid;
if (!appId || !img.src || img.src.indexOf('data:image') === 0) return;
const id = String(appId);
_coverCache.set(id, img.src);
try { localStorage.setItem('sme_cover_' + id, img.src); } catch(e) {}
}
/** 渲染补充包面板 */
function renderBoosterPanel() {
if (!boosterPanelEl) return;
const container = boosterPanelEl.querySelector('.sme-booster-content');
if (!container) return;
// 获取过滤后的游戏列表
let games = [];
for (const key in boosterGameInfo) {
const info = boosterGameInfo[key];
if (!info || !info.appid) continue;
const appid = String(info.appid);
// 搜索过滤
if (boosterSearchKeyword && !(info.name || '').toLowerCase().includes(boosterSearchKeyword.toLowerCase())) continue;
// 分类过滤
if (boosterFilterType === 'queue' && !boosterConfig.queue.includes(appid)) continue;
if (boosterFilterType === 'favorites' && !boosterConfig.favorites.includes(appid)) continue;
if (boosterFilterType === 'blacklist' && !boosterConfig.blacklist.includes(appid)) continue;
if (boosterFilterType === 'available' && getGameStatus(appid) !== 'available') continue;
if (boosterFilterType === 'all' && boosterConfig.blacklist.includes(appid)) continue;
games.push({ appid, name: info.name, series: info.series, price: info.price, status: getGameStatus(appid) });
}
// 排序
games.sort((a, b) => {
let va, vb;
switch (boosterSortKey) {
case 'name': va = (a.name || '').toLowerCase(); vb = (b.name || '').toLowerCase(); break;
case 'gems': va = parseInt(a.price, 10) || 0; vb = parseInt(b.price, 10) || 0; break;
case 'profit': {
const pa = calcBoosterProfit(a.appid);
const pb = calcBoosterProfit(b.appid);
va = pa ? pa.profit : -Infinity;
vb = pb ? pb.profit : -Infinity;
break;
}
case 'cardPrice': {
va = (boosterPriceData[a.appid] && boosterPriceData[a.appid].cardPrice) || 0;
vb = (boosterPriceData[b.appid] && boosterPriceData[b.appid].cardPrice) || 0;
break;
}
case 'status': va = a.status; vb = b.status; break;
default: va = a.name; vb = b.name;
}
if (typeof va === 'string') {
return boosterSortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
}
return boosterSortDir === 'asc' ? va - vb : vb - va;
});
// 分页
const totalPages = Math.max(1, Math.ceil(games.length / boosterPageSize));
if (boosterPageNum > totalPages) boosterPageNum = totalPages;
if (boosterPageNum < 1) boosterPageNum = 1;
const startIdx = (boosterPageNum - 1) * boosterPageSize;
const pageGames = games.slice(startIdx, startIdx + boosterPageSize);
// 构建表格
let tableHtml = '';
if (games.length === 0) {
tableHtml = '没有匹配的游戏
';
} else {
const sortArrow = (key) => boosterSortKey === key ? (boosterSortDir === 'asc' ? ' ▲' : ' ▼') : '';
tableHtml = ``;
tableHtml += `| 封面 | `;
tableHtml += `游戏名称${sortArrow('name')} | `;
tableHtml += `状态${sortArrow('status')} | `;
tableHtml += `宝石成本${sortArrow('gems')} | `;
tableHtml += `卡牌均价${sortArrow('cardPrice')} | `;
tableHtml += `利润${sortArrow('profit')} | `;
tableHtml += `操作 | `;
tableHtml += `链接 | `;
tableHtml += `
`;
for (const g of pageGames) {
const isDone = g.status === 'done';
const isAvailable = g.status === 'available';
const inQueue = boosterConfig.queue.includes(g.appid);
const inFav = boosterConfig.favorites.includes(g.appid);
const inBlack = boosterConfig.blacklist.includes(g.appid);
const profit = calcBoosterProfit(g.appid);
const pd = boosterPriceData[g.appid] || {};
const gemsCost = parseInt(g.price, 10) || 0;
let statusHtml = '';
if (isDone) statusHtml = '已完成';
else if (isAvailable) statusHtml = '可制作';
else statusHtml = '冷却中';
let profitHtml = '--';
if (profit) {
const cls = profit.profit > 0 ? 'sme-booster-profit-positive' : (profit.profit < 0 ? 'sme-booster-profit-negative' : 'sme-booster-profit-neutral');
const sign = profit.profit > 0 ? '+' : '';
profitHtml = `${sign}${formatBoosterPrice(profit.profit)}`;
}
let cardPriceHtml = '点击查询';
if (pd.cardPrice) {
cardPriceHtml = formatBoosterPrice(pd.cardPrice);
if (pd.boosterBuyOrder) {
cardPriceHtml += `
买:${formatBoosterPrice(pd.boosterBuyOrder)}`;
}
} else if (pd.hadCard === false) {
cardPriceHtml = '不可交易';
}
// SVG 图标(使用 ICONS 对象)
const _qIcon = inQueue ? ICONS.queueActive : ICONS.queue;
const _favIcon = inFav ? ICONS.starFilled : ICONS.starOutline;
const _banIcon = inBlack ? ICONS.banActive : ICONS.ban;
const _pkgIcon = ICONS.package;
const _cardIcon = ICONS.card;
tableHtml += ``;
// 封面
tableHtml += ` | `;
// 游戏名称
tableHtml += `${escapeHtml(g.name)} | `;
tableHtml += `${statusHtml} | `;
tableHtml += `${gemsCost.toLocaleString()} | `;
tableHtml += `${cardPriceHtml} | `;
tableHtml += `${profitHtml} | `;
// 操作(纯图标按钮,固定尺寸)
tableHtml += ``;
tableHtml += ``;
tableHtml += ``;
tableHtml += ``;
tableHtml += ` | `;
// 链接
tableHtml += ` | `;
tableHtml += `
`;
}
tableHtml += `
`;
}
// 分页控件
let paginationHtml = '';
if (games.length > boosterPageSize) {
paginationHtml = ``;
}
// 统计信息
let availableCount = 0;
let queueAvailableCount = 0;
let totalGemsCost = 0;
let totalProfit = 0;
let profitCount = 0;
for (const key in boosterGameInfo) {
const appid = String(boosterGameInfo[key].appid);
if (getGameStatus(appid) === 'available') {
availableCount++;
if (boosterConfig.queue.includes(appid)) {
queueAvailableCount++;
totalGemsCost += parseInt(boosterGameInfo[key].price, 10) || 0;
const p = calcBoosterProfit(appid);
if (p) { totalProfit += p.profit; profitCount++; }
}
}
}
const sackPriceDisplay = boosterCustomSackPrice > 0 ? boosterCustomSackPrice : boosterGemsSackPrice;
let infoBarHtml = ``;
infoBarHtml += `宝石余额: ${boosterGemsDisplay.toLocaleString()}`;
infoBarHtml += `一袋宝珠: ${formatBoosterPrice(sackPriceDisplay)}`;
infoBarHtml += `可制作: ${availableCount}`;
infoBarHtml += `队列可做: ${queueAvailableCount}`;
if (queueAvailableCount > 0) {
infoBarHtml += `队列宝石消耗: ${totalGemsCost.toLocaleString()}`;
}
if (profitCount > 0) {
const cls = totalProfit > 0 ? 'sme-booster-profit-positive' : 'sme-booster-profit-negative';
infoBarHtml += `队列预期利润: ${totalProfit > 0 ? '+' : ''}${formatBoosterPrice(totalProfit)}`;
}
infoBarHtml += `
`;
container.innerHTML = tableHtml + paginationHtml + infoBarHtml;
// 封面图降级链:error→尝试下一个CDN,load→缓存成功URL
container.querySelectorAll('img.sme-booster-cover').forEach(img => {
img.addEventListener('error', () => handleCoverError(img));
img.addEventListener('load', () => handleCoverLoad(img));
});
// 绑定表头排序事件
container.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (boosterSortKey === key) {
boosterSortDir = boosterSortDir === 'asc' ? 'desc' : 'asc';
} else {
boosterSortKey = key;
boosterSortDir = 'asc';
}
renderBoosterPanel();
});
});
// 绑定分页事件
container.querySelectorAll('.sme-booster-pagination button[data-page]').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.page === 'prev') boosterPageNum--;
else boosterPageNum++;
renderBoosterPanel();
});
});
// 绑定操作按钮事件
container.querySelectorAll('.sme-booster-action-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = btn.closest('tr');
if (!row) return;
const appid = row.dataset.appid;
const action = btn.dataset.action;
handleBoosterAction(appid, action);
});
});
// 异步获取英文名称并更新补充包链接(避免中文名导致链接失效)
updateBoosterLinksAsync(pageGames);
}
/** 异步获取当前页游戏的英文名称,更新补充包链接 href */
async function updateBoosterLinksAsync(pageGames) {
if (!pageGames || pageGames.length === 0) return;
// 筛选需要获取英文名称的游戏
const needFetch = pageGames.filter(g => !boosterEnglishNameCache[String(g.appid)]);
if (needFetch.length > 0) {
// 并发获取(限制并发数 3),避免触发 Steam API 速率限制
const CONCURRENCY = 3;
for (let i = 0; i < needFetch.length; i += CONCURRENCY) {
const batch = needFetch.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(g => fetchEnglishName(g.appid)));
if (i + CONCURRENCY < needFetch.length) await sleep(300);
}
// 获取完成后更新当前页面中所有补充包链接的 href
if (boosterPanelEl) {
for (const g of pageGames) {
const row = boosterPanelEl.querySelector(`tr[data-appid="${g.appid}"]`);
if (row) {
const link = row.querySelector('.sme-booster-link');
if (link) link.href = buildBoosterMarketUrl(g.appid, g.name);
}
}
}
}
}
/** 处理游戏操作 */
function handleBoosterAction(appid, action) {
appid = String(appid);
const idxQueue = boosterConfig.queue.indexOf(appid);
const idxFav = boosterConfig.favorites.indexOf(appid);
const idxBlack = boosterConfig.blacklist.indexOf(appid);
if (action === 'queue') {
if (idxQueue > -1) boosterConfig.queue.splice(idxQueue, 1);
else {
boosterConfig.queue.push(appid);
if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1);
}
} else if (action === 'fav') {
if (idxFav > -1) boosterConfig.favorites.splice(idxFav, 1);
else {
boosterConfig.favorites.push(appid);
if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1);
}
} else if (action === 'black') {
if (idxBlack > -1) boosterConfig.blacklist.splice(idxBlack, 1);
else {
boosterConfig.blacklist.push(appid);
if (idxQueue > -1) boosterConfig.queue.splice(idxQueue, 1);
if (idxFav > -1) boosterConfig.favorites.splice(idxFav, 1);
}
}
saveBoosterConfig();
renderBoosterPanel();
}
/** 自动查询卡牌价格 */
async function autoQueryCardPrices() {
if (boosterQueryRunning) { boosterToast('查询正在进行中', 'warning'); return; }
boosterQueryRunning = true;
// 获取当前过滤后的游戏列表
const games = [];
for (const key in boosterGameInfo) {
const info = boosterGameInfo[key];
if (!info || !info.appid) continue;
const appid = String(info.appid);
if (boosterSearchKeyword && !(info.name || '').toLowerCase().includes(boosterSearchKeyword.toLowerCase())) continue;
if (boosterFilterType === 'queue' && !boosterConfig.queue.includes(appid)) continue;
if (boosterFilterType === 'favorites' && !boosterConfig.favorites.includes(appid)) continue;
if (boosterFilterType === 'blacklist' && !boosterConfig.blacklist.includes(appid)) continue;
if (boosterFilterType === 'available' && getGameStatus(appid) !== 'available') continue;
if (boosterFilterType === 'all' && boosterConfig.blacklist.includes(appid)) continue;
// 跳过已查询的
if (boosterPriceData[appid] && boosterPriceData[appid].hadCard) continue;
games.push({ appid, name: info.name });
}
if (games.length === 0) {
boosterToast('没有需要查询的游戏', 'info');
boosterQueryRunning = false;
return;
}
boosterToast(`开始查询 ${games.length} 个游戏的卡牌价格...`, 'info');
for (let i = 0; i < games.length; i++) {
if (!boosterQueryRunning) { boosterToast('查询已停止', 'warning'); break; }
const g = games[i];
try {
const result = await fetchCardPrice(g.appid);
if (!boosterPriceData[g.appid]) boosterPriceData[g.appid] = {};
if (result) {
boosterPriceData[g.appid].cardPrice = result.cardPrice;
boosterPriceData[g.appid].cardCount = result.cardCount;
boosterPriceData[g.appid].hadCard = true;
} else {
boosterPriceData[g.appid].hadCard = false;
}
// 同时查询补充包价格
if (boosterConfig.autoQueryBooster) {
const boosterResult = await fetchBoosterMarketPrice(g.appid, g.name);
if (boosterResult) {
boosterPriceData[g.appid].boosterBuyOrder = boosterResult.buyOrder;
boosterPriceData[g.appid].boosterSellOrder = boosterResult.sellOrder;
boosterPriceData[g.appid].hadBooster = true;
}
}
saveBoosterPriceCache();
renderBoosterPanel();
boosterToast(`[${i + 1}/${games.length}] ${g.name}`, 'info');
} catch (e) {
W(`Query failed for ${g.appid}:`, e.message);
}
}
boosterQueryRunning = false;
boosterToast('卡牌价格查询完成', 'success');
renderBoosterPanel();
}
/** 一键制作队列中的补充包 */
async function batchCreateBoosters() {
if (boosterCreateRunning) { boosterToast('制作正在进行中', 'warning'); return; }
// 获取队列中可制作的游戏
const available = [];
for (const appid of boosterConfig.queue) {
if (getGameStatus(appid) === 'available') {
const info = boosterGameInfo[appid];
if (info) available.push({ appid: String(appid), name: info.name, series: info.series, price: info.price });
}
}
if (available.length === 0) {
boosterToast('队列中没有可制作的补充包', 'warning');
return;
}
// 确认对话框
const totalGems = available.reduce((s, g) => s + (parseInt(g.price, 10) || 0), 0);
const confirmed = await showConfirmDialog(
`确定要制作 ${available.length} 个补充包吗?
消耗 ${totalGems.toLocaleString()} 颗宝石`,
{ title: '一键制作补充包', okText: '确认制作', okClass: 'sme-confirm-blue' }
);
if (!confirmed) return;
boosterCreateRunning = true;
const btn = boosterPanelEl.querySelector('#sme-booster-batch-btn');
if (btn) { btn.disabled = true; btn.textContent = '制作中...'; }
const progressBar = boosterPanelEl.querySelector('.sme-booster-progress');
const progressBarBar = boosterPanelEl.querySelector('.sme-booster-progress-bar');
if (progressBar) progressBar.classList.add('sme-visible');
let success = 0;
let failed = 0;
for (let i = 0; i < available.length; i++) {
const g = available[i];
if (progressBarBar) progressBarBar.style.width = `${((i) / available.length) * 100}%`;
try {
await createBoosterPack(g.appid, g.series);
success++;
boosterDoneList.push(g.appid);
boosterToast(`[${i + 1}/${available.length}] ${g.name} 制作成功`, 'success');
L(`Booster created: ${g.appid} (${g.name})`);
} catch (e) {
failed++;
boosterToast(`[${i + 1}/${available.length}] ${g.name} 失败: ${e.message}`, 'error');
W(`Booster create failed: ${g.appid}`, e.message);
if (e.message.includes('宝石') || e.message.includes('gem')) break;
}
// 间隔1.5秒避免限流
if (i < available.length - 1) await new Promise(r => setTimeout(r, 1500));
}
if (progressBarBar) progressBarBar.style.width = '100%';
setTimeout(() => { if (progressBar) progressBar.classList.remove('sme-visible'); }, 2000);
boosterCreateRunning = false;
if (btn) { btn.disabled = false; btn.textContent = '一键制作'; }
// 更新宝石显示
const newGems = getBoosterGems();
if (newGems > 0) boosterGemsDisplay = newGems;
boosterToast(`制作完成: 成功 ${success}, 失败 ${failed}`, success > 0 ? 'success' : 'error');
renderBoosterPanel();
}
/** 初始化补充包制作器页面 */
function initBoosterPage() {
L('Initializing booster creator page');
loadBoosterConfig();
loadBoosterPriceCache();
loadBoosterEnglishNameCache();
// 等待页面加载
const tryInit = (attempt) => {
const boosterArea = document.querySelector('.booster_creator_area');
if (!boosterArea) {
if (attempt < 30) setTimeout(() => tryInit(attempt + 1), 500);
return;
}
// 获取游戏数据
try {
boosterGameInfo = unsafeWindow.CBoosterCreatorPage?.sm_rgBoosterData;
} catch (e) {}
if (!boosterGameInfo) {
if (attempt < 30) {
W('Booster data not ready, retrying...', attempt);
setTimeout(() => tryInit(attempt + 1), 500);
return;
}
L('Booster data not found after timeout');
}
// 获取当前宝石数量
boosterGemsDisplay = getBoosterGems();
// 创建面板
boosterPanelEl = h('div', { class: 'sme-booster-panel' });
// 头部
const header = h('div', { class: 'sme-booster-header' });
header.appendChild(h('div', { class: 'sme-booster-title' }, [
h('span', { html: ICONS.package, style: { display: 'inline-flex', alignItems: 'center' } }),
'补充包制作器增强',
]));
header.appendChild(h('div', { class: 'sme-booster-gems', html: `${ICONS.gem} ${boosterGemsDisplay.toLocaleString()} 宝石` }));
// 操作说明提示条
const hints = h('div', { class: 'sme-booster-hints' });
const _hintSvg = (svg) => svg.replace('class="sme-svg"', '');
const hintItems = [
{ icon: _hintSvg(ICONS.queue), bg: 'var(--sme-green-a20)', color: 'var(--sme-green)', text: '队列:加入制作队列,一键制作时批量执行' },
{ icon: _hintSvg(ICONS.starFilled), bg: 'var(--sme-amber-a20)', color: 'var(--sme-amber)', text: '收藏:标记常玩游戏,方便快速筛选' },
{ icon: _hintSvg(ICONS.ban), bg: 'var(--sme-red-a20)', color: 'var(--sme-red)', text: '拉黑:隐藏不感兴趣的游戏' },
];
hints.innerHTML = hintItems.map(hint =>
`${hint.icon}${hint.text}`
).join('');
const _infoIcon = '';
hints.innerHTML += `${_infoIcon} 先查询卡牌均价查看利润,再一键制作队列中的游戏`;
// 工具栏
const toolbar = h('div', { class: 'sme-booster-toolbar' });
// --- 组1: 搜索 + 过滤 ---
const grp1 = h('div', { class: 'sme-booster-tb-group' });
// 搜索框
const searchInput = h('input', {
type: 'text',
class: 'sme-booster-search',
placeholder: '搜索游戏名称…',
value: boosterSearchKeyword,
'aria-label': '搜索游戏名称',
});
searchInput.addEventListener('input', () => {
boosterSearchKeyword = searchInput.value;
boosterPageNum = 1;
renderBoosterPanel();
});
grp1.appendChild(searchInput);
// 过滤下拉
const filterSelect = h('select', { class: 'sme-booster-select', 'aria-label': '过滤游戏' });
const filterOptions = [
{ value: 'all', label: '全部' },
{ value: 'queue', label: '队列' },
{ value: 'favorites', label: '收藏' },
{ value: 'available', label: '可制作' },
{ value: 'blacklist', label: '黑名单' },
];
for (const opt of filterOptions) {
const o = h('option', { value: opt.value }, opt.label);
if (boosterFilterType === opt.value) o.setAttribute('selected', '');
filterSelect.appendChild(o);
}
filterSelect.addEventListener('change', () => {
boosterFilterType = filterSelect.value;
boosterPageNum = 1;
renderBoosterPanel();
});
grp1.appendChild(filterSelect);
toolbar.appendChild(grp1);
// 分隔线
toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' }));
// --- 组2: 每页 + 宝珠价 ---
const grp2 = h('div', { class: 'sme-booster-tb-group' });
// 每页数量
grp2.appendChild(h('span', { style: { fontSize: '12px', color: 'var(--sme-text-dim)' } }, '每页:'));
const pageSizeInput = h('input', {
type: 'number',
class: 'sme-booster-search',
style: { width: '55px', textAlign: 'center', paddingLeft: '5px', backgroundImage: 'none' },
value: String(boosterPageSize),
min: '5',
max: '100',
title: '每页显示数量',
'aria-label': '每页显示数量',
});
pageSizeInput.addEventListener('change', () => {
const n = parseInt(pageSizeInput.value, 10);
if (n >= 5 && n <= 100) {
boosterPageSize = n;
boosterConfig.pageSize = n;
saveBoosterConfig();
boosterPageNum = 1;
renderBoosterPanel();
}
});
grp2.appendChild(pageSizeInput);
// 自定义宝珠价格
grp2.appendChild(h('span', { style: { fontSize: '12px', color: 'var(--sme-text-dim)' } }, '宝珠价:'));
const customPriceInput = h('input', {
type: 'number',
class: 'sme-booster-search',
style: { width: '65px', textAlign: 'center', paddingLeft: '5px', backgroundImage: 'none' },
value: boosterCustomSackPrice > 0 ? String((boosterCustomSackPrice / 100).toFixed(2)) : '',
placeholder: '自动',
step: '0.01',
title: '自定义一袋宝珠成本价(留空使用市场价)',
'aria-label': '自定义一袋宝珠价格',
});
customPriceInput.addEventListener('change', () => {
const v = parseFloat(customPriceInput.value);
if (!isNaN(v) && v > 0) {
boosterCustomSackPrice = Math.round(v * 100);
boosterConfig.customSackPrice = boosterCustomSackPrice;
} else {
boosterCustomSackPrice = 0;
boosterConfig.customSackPrice = 0;
}
saveBoosterConfig();
renderBoosterPanel();
});
grp2.appendChild(customPriceInput);
toolbar.appendChild(grp2);
// 分隔线
toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' }));
// --- 组3: 查询 ---
const grp3 = h('div', { class: 'sme-booster-tb-group' });
// 查询按钮
const queryBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-amber', 'aria-label': '查询卡牌均价' });
queryBtn.innerHTML = ICONS.search + '查询卡牌均价';
queryBtn.addEventListener('click', () => {
if (boosterQueryRunning) {
boosterQueryRunning = false;
queryBtn.innerHTML = ICONS.search + '查询卡牌均价';
} else {
queryBtn.innerHTML = '停止查询';
autoQueryCardPrices().then(() => {
queryBtn.innerHTML = ICONS.search + '查询卡牌均价';
});
}
});
grp3.appendChild(queryBtn);
// 查询补充包复选框
const autoBoosterLabel = h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: 'var(--sme-text-dim)', cursor: 'pointer' } });
const autoBoosterCheckbox = h('input', { type: 'checkbox', 'aria-label': '同时查询补充包价格' });
if (boosterConfig.autoQueryBooster) autoBoosterCheckbox.setAttribute('checked', '');
autoBoosterCheckbox.addEventListener('change', () => {
boosterConfig.autoQueryBooster = autoBoosterCheckbox.checked;
saveBoosterConfig();
});
autoBoosterLabel.appendChild(autoBoosterCheckbox);
autoBoosterLabel.appendChild(document.createTextNode('同时查补充包'));
grp3.appendChild(autoBoosterLabel);
toolbar.appendChild(grp3);
// 分隔线
toolbar.appendChild(h('div', { class: 'sme-booster-tb-sep' }));
// --- 组4: 制作 + 清缓存 ---
const grp4 = h('div', { class: 'sme-booster-tb-group' });
// 一键制作按钮(主要操作)
const batchBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-green', id: 'sme-booster-batch-btn', 'aria-label': '一键制作队列中的补充包' });
batchBtn.innerHTML = ICONS.zap + '一键制作';
batchBtn.addEventListener('click', batchCreateBoosters);
grp4.appendChild(batchBtn);
// 清空缓存按钮(危险操作,小号字体)
const clearCacheBtn = h('button', { class: 'sme-booster-btn sme-booster-btn-red', 'aria-label': '清空所有价格缓存' }, '清空缓存');
clearCacheBtn.addEventListener('click', async () => {
if (await showConfirmDialog('确定清空所有卡牌价格缓存?', { title: '清空缓存', okText: '清空', okClass: 'sme-dialog-btn sme-dialog-ok' })) {
boosterPriceData = {};
saveBoosterPriceCache();
renderBoosterPanel();
boosterToast('价格缓存已清空', 'success');
}
});
grp4.appendChild(clearCacheBtn);
toolbar.appendChild(grp4);
// 内容区域
const content = h('div', { class: 'sme-booster-content' });
// 进度条
const progress = h('div', { class: 'sme-booster-progress' }, [
h('div', { class: 'sme-booster-progress-bar' }),
]);
boosterPanelEl.appendChild(header);
boosterPanelEl.appendChild(hints);
boosterPanelEl.appendChild(toolbar);
boosterPanelEl.appendChild(content);
boosterPanelEl.appendChild(progress);
// 插入到页面
if (boosterArea && boosterArea.parentNode) {
boosterArea.parentNode.insertBefore(boosterPanelEl, boosterArea.nextSibling);
} else {
const mainContent = document.querySelector('.maincontent');
if (mainContent) mainContent.appendChild(boosterPanelEl);
}
// 渲染初始表格
renderBoosterPanel();
// 异步获取宝石市场价格
fetchGemsSackPrice().then(price => {
boosterGemsSackPrice = price;
L('Gems sack price:', price);
renderBoosterPanel();
}).catch(e => W('Failed to get gems sack price:', e.message));
};
tryInit(0);
}
/** 清理补充包页面 */
function cleanupBoosterPage() {
if (boosterPanelEl) { boosterPanelEl.remove(); boosterPanelEl = null; }
boosterQueryRunning = false;
boosterCreateRunning = false;
boosterDoneList = [];
boosterGameInfo = null;
boosterPriceData = {};
boosterEnglishNameCache = {};
_boosterEnNameFetching.clear();
}
// ==================== 启动(等待 URL 稳定) ====================
let stableSince = 0;
let lastCheck = location.href;
let domReady = false;
function isOnMarket() {
return /^\/market(?:\/|$)/.test(location.pathname)
|| /^\/(?:id|profiles)\/[^/]+\/inventory/.test(location.pathname)
|| /^\/tradeoffer/.test(location.pathname)
|| /^\/tradingcards\/boostercreator/.test(location.pathname);
}
function tryStart() {
if (!domReady) return;
const now = Date.now();
const curURL = location.href;
if (curURL !== lastCheck) {
lastCheck = curURL;
stableSince = now;
return;
}
if (now - stableSince < 800) return;
if (!isOnMarket()) return;
clearInterval(stableTimer);
init();
}
let stableTimer;
function onDOMReady() {
domReady = true;
stableSince = Date.now();
lastCheck = location.href;
stableTimer = setInterval(tryStart, 250);
setTimeout(() => {
if (domReady && isOnMarket() && !initialized) {
clearInterval(stableTimer);
init();
}
}, 2000);
}
// v1.5.0:显示更多价格——必须在 document-start、页面 react-dom 初始化前注入假 DevTools hook,
// 因此这里随脚本执行同步调用(不能放进 init:init 要等 DOM ready,届时 react-dom 早已初始化)
if (settings.get('showMorePrices') && /^\/market\/listings\/\d+\/.+/.test(location.pathname)) {
installShowMoreHook();
}
if (document.readyState === 'interactive' || document.readyState === 'complete') {
onDOMReady();
} else {
document.addEventListener('DOMContentLoaded', onDOMReady);
}
})();