// ==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 = `用量监控`; 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); })();