818 lines
38 KiB
JavaScript
818 lines
38 KiB
JavaScript
// ==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);
|
||
})();
|