Files
LTHDNavigationPageTamper/ExtendWebAITokenChart.js
T
2026-08-08 10:47:59 +08:00

818 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name AI 用量监控与历史图表
// @namespace http://tampermonkey.net/
// @version 5.1
// @description 记录额度历史、展示总用量和区间增量,并支持剪贴板导入导出合并。
// @author AI Assistant
// @match http://192.168.0.87:8787/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_setClipboard
// @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 PANEL_LAYOUT_STORAGE_KEY = 'aiUsagePanelLayoutV1';
const EXPORT_PREFIX = 'AI_USAGE_HISTORY_V1:';
const pageWindow = typeof unsafeWindow === 'undefined' || typeof unsafeWindow.fetch !== 'function' ? window : unsafeWindow;
// 兼容 ExtendWebUIDark.js:该脚本会全局反色,并单独还原 canvas。
const usesInversionDarkMode = () => Boolean(document.getElementById('tm-dark-filter'));
let historyByAssignment = {};
let assignmentNames = {};
let currentQuotaByAssignment = {};
let selectedAssignmentId = '';
let selectedRange = 'today';
let myChart = null;
let timerId = null;
let isProcessing = false;
let isClipboardBusy = false;
let refreshAnimationId = null;
let refreshAnimationStartedAt = 0;
const REFRESH_ANIMATION_DURATION = 1200;
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) {
const timestamp = Array.isArray(point) ? point[0] : point?.timestamp;
const used = Array.isArray(point) ? point[1] : point?.used;
if (!Number.isFinite(Number(timestamp)) || !Number.isFinite(Number(used))) return null;
return [Number(timestamp), Number(used)];
}
function toChartPoint(point) {
return { timestamp: point[0], used: point[1] };
}
function aggregatePoints(points, intervalMs) {
const buckets = new Map();
points.forEach(point => {
const bucket = Math.floor(point[0] / intervalMs) * intervalMs;
const previous = buckets.get(bucket);
if (!previous || point[0] > previous[0]) buckets.set(bucket, point);
});
return Array.from(buckets.values()).sort((a, b) => a[0] - b[0]);
}
function compactPoints(points) {
const cutoff = Date.now() - 2 * 86400000;
const unique = new Map(points.map(normalizePoint).filter(Boolean).map(point => [point[0], point]));
const all = Array.from(unique.values()).sort((a, b) => a[0] - b[0]);
return {
recent: all.filter(point => point[0] >= cutoff),
archived: aggregatePoints(all.filter(point => point[0] < cutoff), 86400000)
};
}
function extractStoredPoints(stored) {
if (Array.isArray(stored)) return stored;
if (!stored || typeof stored !== 'object') return [];
if (Array.isArray(stored.recent) || Array.isArray(stored.archived)) return [...(stored.recent || []), ...(stored.archived || [])];
return Object.values(stored).flatMap(points => Array.isArray(points) ? points : []);
}
function normalizeHistory(raw) {
const normalized = {};
if (!raw || typeof raw !== 'object') return normalized;
Object.entries(raw).forEach(([assignmentId, stored]) => {
const compact = compactPoints(extractStoredPoints(stored));
if (compact.recent.length || compact.archived.length) normalized[assignmentId] = compact;
});
return normalized;
}
function getAssignmentPoints(assignmentId) {
const history = historyByAssignment[assignmentId] || { recent: [], archived: [] };
return [...history.archived, ...history.recent].sort((a, b) => a[0] - b[0]).map(toChartPoint);
}
async function loadHistory() {
historyByAssignment = normalizeHistory(await GM_getValue(STORAGE_KEY, {}));
}
async function saveHistory() {
historyByAssignment = normalizeHistory(historyByAssignment);
await GM_setValue(STORAGE_KEY, historyByAssignment);
}
function constrainPanel(container, layout = {}) {
const viewportWidth = Math.max(1, window.innerWidth);
const viewportHeight = Math.max(1, window.innerHeight);
const minWidth = Math.min(PANEL_MIN_WIDTH, viewportWidth);
const minHeight = Math.min(PANEL_MIN_HEIGHT, viewportHeight);
const width = Math.min(Math.max(minWidth, Number(layout.width) || container.getBoundingClientRect().width), viewportWidth);
const height = Math.min(Math.max(minHeight, Number(layout.height) || container.getBoundingClientRect().height), viewportHeight);
const fallbackLeft = Math.max(0, viewportWidth - width - 20);
const left = Math.min(Math.max(0, Number.isFinite(Number(layout.left)) ? Number(layout.left) : fallbackLeft), viewportWidth - width);
const top = Math.min(Math.max(0, Number.isFinite(Number(layout.top)) ? Number(layout.top) : 20), viewportHeight - height);
Object.assign(container.style, { left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px`, right: 'auto', bottom: 'auto' });
}
async function restorePanelLayout(container) {
const layout = await GM_getValue(PANEL_LAYOUT_STORAGE_KEY, {});
constrainPanel(container, layout);
myChart?.resize();
myChart?.update('none');
}
function savePanelLayout(container) {
const rect = container.getBoundingClientRect();
GM_setValue(PANEL_LAYOUT_STORAGE_KEY, { left: rect.left, top: rect.top, width: rect.width, height: rect.height });
}
function getRefreshButtons() {
return Array.from(document.querySelectorAll('button[data-action="refresh"][data-assignment-id]'));
}
function refreshAssignments() {
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] || '';
const select = document.getElementById('assignment-selector');
if (!select) return;
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 setPanelMinimized(container, minimized) {
const restoreButton = document.getElementById('usage-monitor-restore-button');
container.dataset.minimized = minimized ? 'true' : '';
container.style.display = minimized ? 'none' : 'flex';
if (restoreButton) restoreButton.style.display = minimized ? 'block' : 'none';
if (!minimized) {
myChart?.resize();
myChart?.update('none');
}
}
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" title="选择要查看历史的页面凭据" style="min-width:0;flex:1;background:${palette.control};color:${palette.text};border:1px solid ${palette.border};border-radius:5px;padding:4px 6px"></select>`;
const createHeaderButton = (id, label, title, handler, accent = false) => {
const button = document.createElement('button');
button.id = id;
button.type = 'button';
button.textContent = label;
button.title = title;
button.style.cssText = `background:${accent ? '#0ea5e9' : palette.control};color:${palette.text};border:1px solid ${accent ? '#7dd3fc' : palette.border};border-radius:5px;padding:4px 8px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap`;
button.addEventListener('click', handler);
header.appendChild(button);
return button;
};
const manualRefreshButton = createHeaderButton('manual-refresh-button', '刷新', '立即刷新全部凭据用量', () => triggerPageRefresh(), true);
createHeaderButton('export-history-button', '导出', '将全部历史复制到剪贴板', () => runClipboardAction('export'));
createHeaderButton('import-history-button', '导入', '从剪贴板读取历史并合并', () => runClipboardAction('import'));
const minimizeButton = document.createElement('button');
minimizeButton.type = 'button';
minimizeButton.textContent = '';
minimizeButton.title = '最小化图表';
minimizeButton.setAttribute('aria-label', '最小化图表');
minimizeButton.style.cssText = `background:${palette.control};color:${palette.text};border:1px solid ${palette.border};border-radius:5px;padding:2px 8px;font-size:16px;line-height:18px;cursor:pointer;font-weight:700`;
minimizeButton.addEventListener('click', () => setPanelMinimized(container, true));
header.appendChild(minimizeButton);
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 = '使用“导出”复制全部历史;使用“导入”从剪贴板合并历史';
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);
const restoreButton = document.createElement('button');
restoreButton.id = 'usage-monitor-restore-button';
restoreButton.type = 'button';
restoreButton.textContent = '用量监控';
restoreButton.title = '恢复用量监控图表';
restoreButton.style.cssText = `position:fixed;top:12px;right:12px;z-index:9999;display:none;background:${palette.control};color:${palette.text};border:1px solid ${palette.border};border-radius:6px;padding:6px 10px;font-size:12px;font-weight:700;box-shadow:${palette.shadow};cursor:pointer`;
restoreButton.addEventListener('click', () => setPanelMinimized(container, false));
document.body.appendChild(restoreButton);
makeDraggable(container, header);
makeResizable(container, resizer);
document.getElementById('assignment-selector').addEventListener('change', event => {
selectedAssignmentId = event.target.value;
updateChart();
});
refreshAssignments();
initChart();
updateRangeButtons();
restorePanelLayout(container);
}
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('button, 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', () => {
if (!dragging) return;
dragging = false;
savePanelLayout(container);
});
}
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;
savePanelLayout(container);
myChart?.resize();
myChart?.update('none');
});
}
function getRefreshAnimationProgress() {
if (!refreshAnimationStartedAt) return 1;
return Math.min(1, (performance.now() - refreshAnimationStartedAt) / REFRESH_ANIMATION_DURATION);
}
function playRefreshAnimation() {
refreshAnimationStartedAt = performance.now();
cancelAnimationFrame(refreshAnimationId);
const render = () => {
const progress = getRefreshAnimationProgress();
myChart?.draw();
if (progress < 1) refreshAnimationId = requestAnimationFrame(render);
else refreshAnimationStartedAt = 0;
};
refreshAnimationId = requestAnimationFrame(render);
}
function getAverageDailyUsage(points) {
if (points.length < 2) return 0;
const first = points[0];
const last = points[points.length - 1];
const elapsedDays = (last.timestamp - first.timestamp) / 86400000;
return elapsedDays > 0 ? Math.max(0, (last.used - first.used) / elapsedDays) : 0;
}
function getTodayUsage(points) {
const todayPoints = points.filter(point => point.timestamp >= startOfToday());
if (todayPoints.length < 2) return 0;
return Math.max(0, todayPoints[todayPoints.length - 1].used - todayPoints[0].used);
}
const currentUsageLinePlugin = {
id: 'currentUsageLine',
afterDraw(chart) {
const points = getAssignmentPoints(selectedAssignmentId);
const currentPoint = points[points.length - 1];
const quota = currentQuotaByAssignment[selectedAssignmentId];
const yScale = chart.scales.yUsed;
if (!currentPoint || !yScale) return;
const y = yScale.getPixelForValue(currentPoint.used);
const { left, right, top, bottom } = chart.chartArea;
if (y < top || y > bottom) return;
const remaining = numberOrNull(quota?.remaining);
const todayUsage = getTodayUsage(points);
const dailyUsage = getAverageDailyUsage(points);
const estimatedDays = remaining !== null && dailyUsage > 0 ? remaining / dailyUsage : null;
const estimateText = remaining !== null && remaining <= 0
? ' [已用完]'
: estimatedDays === null
? ''
: estimatedDays < 1
? ' [预计今天全部使用完毕]'
: ` [预计 ${Math.ceil(estimatedDays)} 天使用完毕]`;
const text = remaining === null
? `当前 ${currentPoint.used.toFixed(6)} CNY / 今日用量 ${todayUsage.toFixed(6)} CNY`
: `当前 ${currentPoint.used.toFixed(6)} CNY / 剩余 ${remaining.toFixed(6)} CNY / 今日用量 ${todayUsage.toFixed(6)} CNY${estimateText}`;
const context = chart.ctx;
context.save();
context.setLineDash([7, 5]);
context.strokeStyle = '#facc15';
context.lineWidth = 2;
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
context.setLineDash([]);
context.font = 'bold 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
context.textAlign = 'right';
context.textBaseline = 'bottom';
context.fillStyle = '#fef08a';
context.fillText(text, right - 6, y - 5);
context.restore();
}
};
const refreshSweepPlugin = {
id: 'refreshSweep',
afterDatasetsDraw(chart) {
if (!refreshAnimationStartedAt) return;
const dataset = chart.getDatasetMeta(0);
const points = dataset.data;
if (points.length < 2) return;
const progress = getRefreshAnimationProgress();
const { left, right, bottom } = chart.chartArea;
const sweepX = left + (right - left) * progress;
const context = chart.ctx;
const glow = context.createLinearGradient(sweepX - 90, 0, sweepX + 90, 0);
glow.addColorStop(0, 'rgba(125,211,252,0)');
glow.addColorStop(.5, 'rgba(224,242,254,.48)');
glow.addColorStop(1, 'rgba(125,211,252,0)');
// 用总用量折线与基线构成裁剪区域,扫光只会出现在该折线的填充范围内。
context.save();
context.beginPath();
context.moveTo(points[0].x, bottom);
points.forEach(point => context.lineTo(point.x, point.y));
context.lineTo(points[points.length - 1].x, bottom);
context.closePath();
context.clip();
context.fillStyle = glow;
context.fillRect(sweepX - 90, chart.chartArea.top, 180, bottom - chart.chartArea.top);
context.restore();
}
};
function initChart() {
const ctx = document.getElementById('usageChart').getContext('2d');
myChart = new Chart(ctx, {
type: 'line',
plugins: [refreshSweepPlugin, currentUsageLinePlugin],
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 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).map(point => [point.timestamp, point.used]), 3600000).map(toChartPoint);
}
return allPoints.filter(point => point.timestamp >= now - 30 * 86400000);
}
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 = [Date.now(), used];
currentQuotaByAssignment[assignmentId] = { remaining: numberOrNull(quota.remaining) };
historyByAssignment[assignmentId] = historyByAssignment[assignmentId] || { recent: [], archived: [] };
historyByAssignment[assignmentId].recent.push(point);
historyByAssignment[assignmentId] = compactPoints([
...historyByAssignment[assignmentId].archived,
...historyByAssignment[assignmentId].recent
]);
assignmentNames[assignmentId] = assignmentNames[assignmentId] || assignmentId;
if (!selectedAssignmentId) selectedAssignmentId = assignmentId;
refreshAssignments();
updateChart();
playRefreshAnimation();
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 manualRefreshButton = document.getElementById('manual-refresh-button');
if (manualRefreshButton) {
manualRefreshButton.disabled = true;
manualRefreshButton.textContent = '刷新中';
manualRefreshButton.style.opacity = '0.65';
manualRefreshButton.style.cursor = 'wait';
}
const buttons = getRefreshButtons();
if (!buttons.length) {
setStatus('未找到刷新额度按钮', '#dc3545');
if (manualRefreshButton) {
manualRefreshButton.disabled = false;
manualRefreshButton.textContent = '刷新';
manualRefreshButton.style.opacity = '1';
manualRefreshButton.style.cursor = 'pointer';
}
scheduleNextRun();
return;
}
isProcessing = true;
refreshAssignments();
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;
if (manualRefreshButton) {
manualRefreshButton.disabled = false;
manualRefreshButton.textContent = '刷新';
manualRefreshButton.style.opacity = '1';
manualRefreshButton.style.cursor = 'pointer';
}
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, history]) => [
id,
assignmentNames[id] || '',
[...history.archived, ...history.recent]
]);
return { v: 3, a: assignments };
}
async function writeClipboard(text) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
return;
}
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 readClipboard() {
if (navigator.clipboard && window.isSecureContext) return (await navigator.clipboard.readText()).trim();
const text = window.prompt('当前 HTTP 页面不允许脚本直接读取剪贴板。请粘贴导出的完整数据后确认:');
if (text === null) throw new Error('已取消导入');
return text.trim();
}
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, 3].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 === 2
? source.flatMap(day => Array.isArray(day) && Array.isArray(day[1]) ? day[1] : [])
: source;
const existing = new Set(getAssignmentPoints(assignmentId).map(point => point.timestamp));
const merged = [
...(historyByAssignment[assignmentId]?.archived || []),
...(historyByAssignment[assignmentId]?.recent || [])
];
rows.forEach(row => {
const point = normalizePoint(row);
if (!point || existing.has(point[0])) return;
merged.push(point);
existing.add(point[0]);
imported += 1;
});
historyByAssignment[assignmentId] = compactPoints(merged);
assignmentNames[assignmentId] = assignmentNames[assignmentId] || name || assignmentId;
});
return imported;
}
async function importHistory() {
const text = await readClipboard();
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();
refreshAssignments();
updateChart();
setStatus(`已导入 ${imported} 条新记录`, '#28a745');
}
async function runClipboardAction(action) {
if (isClipboardBusy) return;
isClipboardBusy = true;
try {
if (action === 'export') 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();
updateChart();
setStatus('已启动,正在首次刷新...', '#6c757d');
triggerPageRefresh();
window.addEventListener('resize', () => {
const container = document.getElementById('usage-monitor-container');
if (!container) return;
const rect = container.getBoundingClientRect();
constrainPanel(container, rect);
savePanelLayout(container);
myChart?.resize();
myChart?.update('none');
});
window.addEventListener('beforeunload', () => {
clearTimeout(timerId);
cancelAnimationFrame(refreshAnimationId);
});
console.log('[用量监控] 脚本 V5.0 已启动。');
}
if (document.readyState === 'complete') start();
else window.addEventListener('load', start);
})();