Files
LTHDNavigationPageTamper/ExtendWebAITokenChart.js
T

615 lines
28 KiB
JavaScript

// ==UserScript==
// @name AI 用量监控与历史图表
// @namespace http://tampermonkey.net/
// @version 5.0
// @description 记录额度历史、展示总用量和区间增量,并支持压缩导入导出。
// @author AI Assistant
// @match http://192.168.0.87:8787/*
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @require https://cdn.jsdelivr.net/npm/chart.js
// ==/UserScript==
(function () {
'use strict';
const POLLING_INTERVAL = 300000;
const REQUEST_TIMEOUT = 30000;
const PANEL_MIN_WIDTH = 340;
const PANEL_MIN_HEIGHT = 240;
const STORAGE_KEY = 'aiUsageHistoryV1';
const EXPORT_PREFIX = 'AI_USAGE_HISTORY_V1:';
const pageWindow = typeof unsafeWindow === 'undefined' ? window : unsafeWindow;
// 兼容 ExtendWebUIDark.js:该脚本会全局反色,并单独还原 canvas。
const usesInversionDarkMode = () => Boolean(document.getElementById('tm-dark-filter'));
let historyByAssignment = {};
let assignmentNames = {};
let selectedAssignmentId = '';
let selectedRange = 'today';
let myChart = null;
let timerId = null;
let isProcessing = false;
let isClipboardBusy = false;
const pendingRefreshes = new Map();
function setStatus(message, color = '#6c757d') {
const status = document.getElementById('monitor-status');
if (status) {
status.textContent = message;
status.style.color = color;
}
}
function numberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizePoint(point) {
if (!point || !Number.isFinite(Number(point.timestamp)) || !Number.isFinite(Number(point.used))) return null;
return {
timestamp: Number(point.timestamp),
used: Number(point.used),
total: numberOrNull(point.total),
remaining: numberOrNull(point.remaining),
unlimited: Boolean(point.unlimited),
unit: typeof point.unit === 'string' ? point.unit : 'CNY',
quotaUpdatedAt: typeof point.quotaUpdatedAt === 'string' ? point.quotaUpdatedAt : ''
};
}
function getDayKey(timestamp) {
const date = new Date(Number(timestamp));
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function normalizeHistory(raw) {
const normalized = {};
if (!raw || typeof raw !== 'object') return normalized;
Object.entries(raw).forEach(([assignmentId, storedDays]) => {
if (!assignmentId || !storedDays || typeof storedDays !== 'object') return;
const days = {};
const sourceDays = Array.isArray(storedDays)
? storedDays.reduce((result, point) => {
const dayKey = point && Number.isFinite(Number(point.timestamp)) ? getDayKey(point.timestamp) : '';
if (dayKey) (result[dayKey] ||= []).push(point);
return result;
}, {})
: storedDays;
Object.entries(sourceDays).forEach(([dayKey, points]) => {
if (!Array.isArray(points)) return;
const unique = new Map();
points.map(normalizePoint).filter(Boolean).forEach(point => unique.set(point.timestamp, point));
if (unique.size) days[dayKey] = Array.from(unique.values()).sort((a, b) => a.timestamp - b.timestamp);
});
if (Object.keys(days).length) normalized[assignmentId] = days;
});
return normalized;
}
function getAssignmentPoints(assignmentId) {
const days = historyByAssignment[assignmentId] || {};
return Object.values(days).flat().sort((a, b) => a.timestamp - b.timestamp);
}
async function loadHistory() {
historyByAssignment = normalizeHistory(await GM_getValue(STORAGE_KEY, {}));
}
async function saveHistory() {
await GM_setValue(STORAGE_KEY, historyByAssignment);
}
function getRefreshButtons() {
return Array.from(document.querySelectorAll('button[data-action="refresh"][data-assignment-id]'));
}
function refreshAssignmentOptions() {
const select = document.getElementById('assignment-selector');
if (!select) return;
getRefreshButtons().forEach(button => {
const id = button.dataset.assignmentId;
if (!id) return;
const card = button.closest('article, section, div');
const text = card ? card.innerText.split('\n')[0].trim() : '';
assignmentNames[id] = text || assignmentNames[id] || id;
});
Object.keys(historyByAssignment).forEach(id => { assignmentNames[id] = assignmentNames[id] || id; });
const ids = Object.keys(assignmentNames);
if (!selectedAssignmentId || !ids.includes(selectedAssignmentId)) selectedAssignmentId = ids[0] || '';
select.replaceChildren();
ids.forEach(id => {
const option = document.createElement('option');
option.value = id;
option.textContent = assignmentNames[id] || id;
select.appendChild(option);
});
select.value = selectedAssignmentId;
}
function createUI() {
const inverted = usesInversionDarkMode();
// 反色模式下,面板本身会被 html 滤镜反转,因此改用对应的浅色原始值。
const palette = inverted
? { surface: '#efe7d8', text: '#071012', border: '#cbd5e1', control: '#e2e8f0', muted: '#475569', shadow: '0 12px 32px rgba(255,255,255,.28)' }
: { surface: '#101827', text: '#f8fafc', border: '#334155', control: '#1e293b', muted: '#94a3b8', shadow: '0 12px 32px rgba(15,23,42,.48)' };
const container = document.createElement('div');
container.id = 'usage-monitor-container';
Object.assign(container.style, {
position: 'fixed', top: '20px', right: '20px', width: '460px', height: '330px',
backgroundColor: palette.surface, color: palette.text, padding: '15px', borderRadius: '10px',
boxShadow: palette.shadow, zIndex: '9999',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontSize: '14px', border: `1px solid ${palette.border}`, boxSizing: 'border-box', display: 'flex', flexDirection: 'column', overflow: 'hidden'
});
const header = document.createElement('div');
header.style.cssText = `display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-shrink:0;color:${palette.text}`;
header.innerHTML = `<strong style="letter-spacing:.02em;white-space:nowrap">用量监控</strong><select id="assignment-selector" style="min-width:0;flex:1;background:${palette.control};color:${palette.text};border:1px solid ${palette.border};border-radius:5px;padding:4px 6px"></select>`;
container.appendChild(header);
const rangeBar = document.createElement('div');
rangeBar.id = 'chart-range-selector';
rangeBar.style.cssText = 'display:flex;gap:6px;margin-bottom:7px;flex-shrink:0';
[['today', '今天'], ['7d', '最近 7 天'], ['30d', '最近 30 天']].forEach(([value, label]) => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.range = value;
button.textContent = label;
button.style.cssText = `background:${palette.control};color:${palette.text};border:1px solid ${palette.border};border-radius:5px;padding:3px 7px;font-size:12px;cursor:pointer`;
button.addEventListener('click', () => {
selectedRange = value;
updateRangeButtons();
updateChart();
});
rangeBar.appendChild(button);
});
container.appendChild(rangeBar);
const status = document.createElement('div');
status.id = 'monitor-status';
status.textContent = '初始化...';
status.style.cssText = `font-size:12px;color:${palette.muted};margin-bottom:4px;flex-shrink:0`;
container.appendChild(status);
const hint = document.createElement('div');
hint.textContent = 'Ctrl+C 导出全部历史 · Ctrl+V 导入并合并';
hint.style.cssText = `font-size:11px;color:${palette.muted};margin-bottom:8px;flex-shrink:0`;
container.appendChild(hint);
const chartBox = document.createElement('div');
chartBox.style.cssText = 'position:relative;flex:1;min-height:0';
const canvas = document.createElement('canvas');
canvas.id = 'usageChart';
chartBox.appendChild(canvas);
container.appendChild(chartBox);
const resizer = document.createElement('div');
Object.assign(resizer.style, { width: '15px', height: '15px', position: 'absolute', right: '1px', bottom: '1px', cursor: 'se-resize', background: 'linear-gradient(135deg, transparent 50%, #38bdf8 50%)' });
container.appendChild(resizer);
document.body.appendChild(container);
makeDraggable(container, header);
makeResizable(container, resizer);
document.getElementById('assignment-selector').addEventListener('change', event => {
selectedAssignmentId = event.target.value;
updateChart();
});
refreshAssignmentOptions();
initChart();
updateRangeButtons();
}
function makeDraggable(container, handle) {
let dragOffsetX = 0;
let dragOffsetY = 0;
let dragging = false;
handle.style.cursor = 'move';
handle.addEventListener('mousedown', event => {
if (event.target.closest('select')) return;
const rect = container.getBoundingClientRect();
dragOffsetX = event.clientX - rect.left;
dragOffsetY = event.clientY - rect.top;
dragging = true;
event.preventDefault();
});
window.addEventListener('mousemove', event => {
if (!dragging) return;
const rect = container.getBoundingClientRect();
const maxLeft = Math.max(0, window.innerWidth - rect.width);
const maxTop = Math.max(0, window.innerHeight - rect.height);
container.style.left = `${Math.min(Math.max(0, event.clientX - dragOffsetX), maxLeft)}px`;
container.style.top = `${Math.min(Math.max(0, event.clientY - dragOffsetY), maxTop)}px`;
container.style.right = 'auto';
container.style.bottom = 'auto';
});
window.addEventListener('mouseup', () => { dragging = false; });
}
function makeResizable(container, resizer) {
let resizing = false;
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
resizer.addEventListener('mousedown', event => {
const rect = container.getBoundingClientRect();
resizing = true;
startX = event.clientX;
startY = event.clientY;
startWidth = rect.width;
startHeight = rect.height;
event.preventDefault();
event.stopPropagation();
});
window.addEventListener('mousemove', event => {
if (!resizing) return;
const rect = container.getBoundingClientRect();
const minWidth = Math.min(PANEL_MIN_WIDTH, window.innerWidth);
const minHeight = Math.min(PANEL_MIN_HEIGHT, window.innerHeight);
const maxWidth = Math.max(minWidth, window.innerWidth - rect.left);
const maxHeight = Math.max(minHeight, window.innerHeight - rect.top);
const width = Math.min(Math.max(minWidth, startWidth + event.clientX - startX), maxWidth);
const height = Math.min(Math.max(minHeight, startHeight + event.clientY - startY), maxHeight);
container.style.width = `${width}px`;
container.style.height = `${height}px`;
myChart?.resize();
});
window.addEventListener('mouseup', () => {
if (!resizing) return;
resizing = false;
myChart?.resize();
myChart?.update('none');
});
}
function initChart() {
const ctx = document.getElementById('usageChart').getContext('2d');
myChart = new Chart(ctx, {
type: 'line',
data: { datasets: [
{ label: '总已用额度 (CNY)', data: [], yAxisID: 'yUsed', borderColor: '#0ea5e9', backgroundColor: 'rgba(14,165,233,.18)', pointBackgroundColor: '#e0f2fe', pointBorderColor: '#38bdf8', fill: true, borderWidth: 2.5, tension: .25 },
{ label: '区间新增用量 (CNY)', data: [], yAxisID: 'yDelta', borderColor: '#f97316', backgroundColor: 'rgba(249,115,22,.15)', pointBackgroundColor: '#ffedd5', pointBorderColor: '#fb923c', borderWidth: 2.5, borderDash: [5, 4], tension: .25 }
] },
options: {
responsive: true, maintainAspectRatio: false, animation: { duration: 0 },
scales: {
// 使用毫秒时间戳作为 x 值,点距会按真实经过时间而非样本数量绘制。
x: {
type: 'linear', grid: { color: '#334155' },
ticks: { color: '#cbd5e1', maxTicksLimit: 6, callback: value => formatAxisTime(value) }
},
yUsed: { position: 'left', grid: { color: '#334155' }, ticks: { color: '#7dd3fc', callback: value => `${Number(value).toFixed(2)} CNY` } },
yDelta: { position: 'right', grid: { drawOnChartArea: false }, ticks: { color: '#fdba74', callback: value => `${Number(value).toFixed(2)} CNY` }, suggestedMin: 0 }
},
plugins: {
legend: { labels: { color: '#e2e8f0', boxWidth: 14 } },
tooltip: {
callbacks: {
title: items => items[0] ? formatTime(items[0].parsed.x) : '',
label: item => `${item.dataset.label}: ${Number(item.parsed.y).toFixed(5)} CNY`
}
}
}
}
});
}
function formatTime(timestamp) {
return new Date(Number(timestamp)).toLocaleString('zh-CN', { hour12: false });
}
function formatAxisTime(timestamp) {
return new Date(Number(timestamp)).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
});
}
function startOfToday() {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function aggregatePoints(points, intervalMs) {
const buckets = new Map();
points.forEach(point => {
const bucket = Math.floor(point.timestamp / intervalMs) * intervalMs;
// 额度为累计值,取每个时间桶最后一次采样,保留该时间段结束时的真实状态。
const previous = buckets.get(bucket);
if (!previous || point.timestamp > previous.timestamp) buckets.set(bucket, point);
});
return Array.from(buckets.values()).sort((a, b) => a.timestamp - b.timestamp);
}
function getDisplayedPoints(assignmentId) {
const allPoints = getAssignmentPoints(assignmentId);
const now = Date.now();
if (selectedRange === 'today') return allPoints.filter(point => point.timestamp >= startOfToday());
if (selectedRange === '7d') return aggregatePoints(allPoints.filter(point => point.timestamp >= now - 7 * 86400000), 3600000);
return aggregatePoints(allPoints.filter(point => point.timestamp >= now - 30 * 86400000), 6 * 3600000);
}
function getPointsWithDelta(assignmentId) {
const points = getDisplayedPoints(assignmentId);
return points.map((point, index) => ({ ...point, delta: index ? point.used - points[index - 1].used : 0 }));
}
function updateRangeButtons() {
document.querySelectorAll('#chart-range-selector button').forEach(button => {
const active = button.dataset.range === selectedRange;
button.style.background = active ? '#0ea5e9' : '';
button.style.borderColor = active ? '#7dd3fc' : '';
button.style.fontWeight = active ? '700' : '400';
});
}
function updateChart() {
if (!myChart) return;
const points = getPointsWithDelta(selectedAssignmentId);
myChart.data.datasets[0].data = points.map(point => ({ x: point.timestamp, y: point.used }));
myChart.data.datasets[1].data = points.map(point => ({ x: point.timestamp, y: point.delta }));
myChart.update();
}
function getRequestUrl(input) {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.href;
if (input && typeof input.url === 'string') return input.url;
return '';
}
function getAssignmentIdFromRequest(input, options) {
let body = options && options.body;
if (input && typeof input === 'object' && input.body && body === undefined) body = input.body;
if (typeof body !== 'string') return '';
try { return JSON.parse(body).assignmentId || ''; } catch (_) { return ''; }
}
function addQuotaPoint(assignmentId, quota) {
const used = numberOrNull(quota && quota.used);
if (!assignmentId || used === null) return false;
const point = normalizePoint({ timestamp: Date.now(), used, total: quota.total, remaining: quota.remaining, unlimited: quota.unlimited, unit: quota.unit, quotaUpdatedAt: quota.updatedAt });
const dayKey = getDayKey(point.timestamp);
historyByAssignment[assignmentId] = historyByAssignment[assignmentId] || {};
historyByAssignment[assignmentId][dayKey] = historyByAssignment[assignmentId][dayKey] || [];
historyByAssignment[assignmentId][dayKey].push(point);
historyByAssignment[assignmentId][dayKey].sort((a, b) => a.timestamp - b.timestamp);
assignmentNames[assignmentId] = assignmentNames[assignmentId] || assignmentId;
if (!selectedAssignmentId) selectedAssignmentId = assignmentId;
refreshAssignmentOptions();
updateChart();
return true;
}
function hookFetch() {
const originalFetch = pageWindow.fetch;
pageWindow.fetch = async function (...args) {
const url = getRequestUrl(args[0]);
const assignmentId = getAssignmentIdFromRequest(args[0], args[1]);
let response;
try {
response = await originalFetch.apply(this, args);
} catch (error) {
if (url.includes('/api/my/refresh') && assignmentId) finishRefresh(assignmentId, error);
throw error;
}
if (url.includes('/api/my/refresh') && assignmentId) {
try {
if (!response.ok) throw new Error(`请求失败 (${response.status})`);
const quota = await response.clone().json();
if (!addQuotaPoint(assignmentId, quota)) throw new Error('响应中缺少有效用量');
finishRefresh(assignmentId);
} catch (error) {
finishRefresh(assignmentId, error);
}
}
return response;
};
}
function finishRefresh(assignmentId, error) {
const pending = pendingRefreshes.get(assignmentId);
if (!pending) return;
clearTimeout(pending.timeout);
pendingRefreshes.delete(assignmentId);
pending.resolve(error);
}
function waitForRefresh(assignmentId) {
return new Promise(resolve => {
const timeout = setTimeout(() => finishRefresh(assignmentId, new Error('等待刷新回调超时')), REQUEST_TIMEOUT);
pendingRefreshes.set(assignmentId, { resolve, timeout });
});
}
async function triggerPageRefresh() {
if (isProcessing) return;
const buttons = getRefreshButtons();
if (!buttons.length) {
setStatus('未找到刷新额度按钮', '#dc3545');
scheduleNextRun();
return;
}
isProcessing = true;
refreshAssignmentOptions();
let successCount = 0;
try {
for (const button of buttons) {
const assignmentId = button.dataset.assignmentId;
if (!assignmentId) continue;
setStatus(`正在刷新 ${assignmentNames[assignmentId] || assignmentId}...`, '#ffc107');
const result = waitForRefresh(assignmentId);
button.click();
const error = await result;
if (error) console.warn(`[用量监控] ${assignmentId} 刷新失败`, error);
else successCount += 1;
}
await saveHistory();
setStatus(`已记录 ${successCount}/${buttons.length} 个凭据`, successCount ? '#28a745' : '#dc3545');
} finally {
isProcessing = false;
scheduleNextRun();
}
}
function scheduleNextRun() {
clearTimeout(timerId);
timerId = setTimeout(triggerPageRefresh, POLLING_INTERVAL);
}
function bytesToBase64(bytes) {
let binary = '';
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary);
}
function base64ToBytes(value) {
const binary = atob(value);
return Uint8Array.from(binary, char => char.charCodeAt(0));
}
async function gzipText(text) {
if (!window.CompressionStream) throw new Error('当前浏览器不支持 gzip 压缩');
const stream = new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function gunzipText(bytes) {
if (!window.DecompressionStream) throw new Error('当前浏览器不支持 gzip 解压');
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
return new Response(stream).text();
}
function buildExportPayload() {
const assignments = Object.entries(historyByAssignment).map(([id, days]) => [
id,
assignmentNames[id] || '',
Object.entries(days).map(([dayKey, points]) => [
dayKey,
points.map(point => [point.timestamp, point.used, point.total, point.remaining, point.unlimited ? 1 : 0, point.unit, point.quotaUpdatedAt])
])
]);
return { v: 2, a: assignments };
}
async function writeClipboard(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
if (!copied) throw new Error('浏览器拒绝写入剪贴板');
}
async function exportHistory() {
const count = Object.keys(historyByAssignment).reduce((sum, assignmentId) => sum + getAssignmentPoints(assignmentId).length, 0);
if (!count) throw new Error('没有可导出的历史记录');
const compressed = await gzipText(JSON.stringify(buildExportPayload()));
const text = EXPORT_PREFIX + bytesToBase64(compressed);
await writeClipboard(text);
setStatus(`已导出 ${count} 条记录(${text.length} 字符)`, '#28a745');
}
function mergeImportPayload(payload) {
if (!payload || ![1, 2].includes(payload.v) || !Array.isArray(payload.a)) throw new Error('历史数据格式无效');
let imported = 0;
payload.a.forEach(entry => {
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || !Array.isArray(entry[2])) return;
const [assignmentId, name, source] = entry;
const rows = payload.v === 1
? source
: source.flatMap(day => Array.isArray(day) && Array.isArray(day[1]) ? day[1] : []);
const existing = new Set(getAssignmentPoints(assignmentId).map(point => point.timestamp));
historyByAssignment[assignmentId] = historyByAssignment[assignmentId] || {};
rows.forEach(row => {
if (!Array.isArray(row)) return;
const point = normalizePoint({ timestamp: row[0], used: row[1], total: row[2], remaining: row[3], unlimited: row[4], unit: row[5], quotaUpdatedAt: row[6] });
if (!point || existing.has(point.timestamp)) return;
const dayKey = getDayKey(point.timestamp);
historyByAssignment[assignmentId][dayKey] = historyByAssignment[assignmentId][dayKey] || [];
historyByAssignment[assignmentId][dayKey].push(point);
existing.add(point.timestamp);
imported += 1;
});
Object.values(historyByAssignment[assignmentId]).forEach(points => points.sort((a, b) => a.timestamp - b.timestamp));
assignmentNames[assignmentId] = assignmentNames[assignmentId] || name || assignmentId;
});
return imported;
}
async function importHistory() {
if (!navigator.clipboard || !window.isSecureContext) throw new Error('当前页面无法读取剪贴板');
const text = (await navigator.clipboard.readText()).trim();
if (!text.startsWith(EXPORT_PREFIX)) throw new Error('剪贴板中不是 AI 用量历史数据');
const payload = JSON.parse(await gunzipText(base64ToBytes(text.slice(EXPORT_PREFIX.length))));
const imported = mergeImportPayload(payload);
if (!imported) throw new Error('未发现可合并的新记录');
await saveHistory();
refreshAssignmentOptions();
updateChart();
setStatus(`已导入 ${imported} 条新记录`, '#28a745');
}
function isEditableTarget(target) {
return target instanceof Element && (target.matches('input, textarea, select') || target.isContentEditable);
}
function installKeyboardShortcuts() {
window.addEventListener('keydown', async event => {
if ((!event.ctrlKey && !event.metaKey) || event.altKey || isEditableTarget(event.target) || isClipboardBusy) return;
const key = event.key.toLowerCase();
if (key !== 'c' && key !== 'v') return;
event.preventDefault();
isClipboardBusy = true;
try {
if (key === 'c') await exportHistory();
else { setStatus('正在导入...', '#ffc107'); await importHistory(); }
} catch (error) {
console.warn('[用量监控] 剪贴板操作失败', error);
setStatus(error.message || '剪贴板操作失败', '#dc3545');
} finally {
isClipboardBusy = false;
}
});
}
async function start() {
await loadHistory();
createUI();
hookFetch();
installKeyboardShortcuts();
updateChart();
setStatus('已启动,正在首次刷新...', '#6c757d');
triggerPageRefresh();
window.addEventListener('resize', () => {
const container = document.getElementById('usage-monitor-container');
if (!container) return;
const maxWidth = Math.max(PANEL_MIN_WIDTH, window.innerWidth - 20);
const maxHeight = Math.max(PANEL_MIN_HEIGHT, window.innerHeight - 20);
const width = Math.min(container.getBoundingClientRect().width, maxWidth);
const height = Math.min(container.getBoundingClientRect().height, maxHeight);
container.style.width = `${width}px`;
container.style.height = `${height}px`;
const rect = container.getBoundingClientRect();
container.style.left = `${Math.min(Math.max(0, rect.left), Math.max(0, window.innerWidth - rect.width))}px`;
container.style.top = `${Math.min(Math.max(0, rect.top), Math.max(0, window.innerHeight - rect.height))}px`;
container.style.right = 'auto';
myChart?.resize();
myChart?.update('none');
});
window.addEventListener('beforeunload', () => clearTimeout(timerId));
console.log('[用量监控] 脚本 V5.0 已启动。');
}
if (document.readyState === 'complete') start();
else window.addEventListener('load', start);
})();