// ==UserScript== // @name Claude & ChatGPT 中文汉化与用量显示 // @namespace https://github.com/maojiebc/Claude-ChatGPT-Usage/ // @homepageURL https://github.com/maojiebc/Claude-ChatGPT-Usage/ // @supportURL https://github.com/maojiebc/Claude-ChatGPT-Usage/issues // @source https://github.com/maojiebc/Claude-ChatGPT-Usage/ // @author jyking (original), maojiebc (maintainer) // @copyright 2026, jyking and maojiebc // @version 1.5.6 // @description Claude.ai 完整中文汉化,并显示 Claude/Fable 5 与 ChatGPT/Codex 剩余用量 // @icon https://assets-proxy.anthropic.com/claude-ai/v2/assets/v1/cd02a42d9-Vq_H3mgS.svg // @match https://claude.ai/* // @match https://chatgpt.com/* // @require https://raw.githubusercontent.com/maojiebc/Claude-ChatGPT-Usage/v1.0.0/claude2cn-design.user.js#sha256=19fefdebcb71584886bfa494aed0e54c4922860f01d9db367e838489ab8afb48 // @require https://raw.githubusercontent.com/maojiebc/Claude-ChatGPT-Usage/v1.0.0/claude2cn-translations.user.js#sha256=587a5de6adf25d5aa19f1e6f58b5bb6181f31e5d89e49669a3c75a85df8ff61a // @require https://raw.githubusercontent.com/maojiebc/Claude-ChatGPT-Usage/v1.5.2/claude-usage-icons.user.js#sha256=9050bccec82b4413ce99420766796c0d6af2dd34aeafa9e49b38c3e169bbe6f5 // @grant none // @license MIT // @run-at document-start // ==/UserScript== (function () { "use strict"; const isClaudeSite = location.hostname === "claude.ai"; const isChatGPTSite = location.hostname === "chatgpt.com"; // 添加 CSS 变量 if (isClaudeSite) { const style = document.createElement("style"); style.textContent = ` :root { --font-anthropic-serif: "Anthropic Serif", Georgia, "Times New Roman", Times, "Noto Serif CJK SC", "Source Han Serif SC", "Noto Serif SC", "Source Hans Serif CN", "Songti SC", SimSun, serif; } `; (document.head || document.documentElement).appendChild(style); const originalFetch = window.fetch; window.fetch = async function (...args) { const url = typeof args[0] === "string" ? args[0] : args[0] instanceof Request ? args[0].url : ""; if ( !url.includes("/i18n/en-US.json") && !url.includes("/i18n/statsig/en-US.json") ) { return originalFetch(...args); } const response = await originalFetch(...args); const json = await response.json(); for (const key of Object.keys(json)) { const val = json[key]; if (typeof val === "string" && TRANSLATIONS[val]) { json[key] = TRANSLATIONS[val]; } } return new Response(JSON.stringify(json), { status: response.status, statusText: response.statusText, headers: response.headers, }); }; } // BEGIN USAGE_PARSERS — 保持为纯函数,便于离线测试未公开接口的响应兼容性。 const UsageParsers = (() => { function asNumber(value) { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function firstNumber(...values) { for (const value of values) { const parsed = asNumber(value); if (parsed !== null) return parsed; } return null; } function toTimestampMs(value) { const numeric = asNumber(value); if (numeric !== null) { return numeric < 10_000_000_000 ? numeric * 1000 : numeric; } if (typeof value !== "string" || !value.trim()) return null; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; } function normalizeWindow(raw, defaultWindowMinutes = null) { if (!raw || typeof raw !== "object") return null; const utilization = firstNumber( raw.utilization, raw.used_percentage, raw.used_percent, raw.percent, ); if (utilization === null) return null; const seconds = firstNumber( raw.limit_window_seconds, raw.window_seconds, ); const minutes = firstNumber(raw.window_minutes, raw.window_duration_minutes); return { utilization: Math.min(100, Math.max(0, utilization)), resets_at: raw.resets_at ?? raw.reset_at ?? raw.resetAt ?? raw.resetsAt ?? null, window_minutes: minutes ?? (seconds !== null ? seconds / 60 : defaultWindowMinutes), }; } function stringValue(value) { return typeof value === "string" ? value.trim() : ""; } function displayModelName(value) { const name = stringValue(value); return /^(?:claude[-_ ]?)?fable(?:[-_ ]?5)?$/i.test(name) ? "Fable 5" : name; } function parseClaude(data) { const empty = { fiveHour: null, sevenDay: null, modelLimits: [], resetCredits: null, planName: "", hit: false, hasScopedSurface: false, }; if (!data || typeof data !== "object") return empty; let fiveHour = normalizeWindow(data.five_hour, 300); let sevenDay = normalizeWindow(data.seven_day, 10_080); const modelLimits = new Map(); function setModelLimit(name, raw, defaultWindowMinutes = 10_080) { const modelName = displayModelName(name); const window = normalizeWindow(raw, defaultWindowMinutes); if (!modelName || !window) return; modelLimits.set(modelName.toLowerCase(), { name: modelName, ...window }); } setModelLimit( "Fable 5", data.seven_day_fable ?? data.fable_seven_day ?? data.fable_weekly, ); if (Array.isArray(data.limits)) { for (const limit of data.limits) { if (!limit || typeof limit !== "object") continue; const kind = stringValue(limit.kind).toLowerCase(); const group = stringValue(limit.group).toLowerCase(); const scopedModel = limit.scope?.model?.display_name ?? limit.scope?.model?.name ?? limit.scope?.model?.id ?? (typeof limit.scope?.model === "string" ? limit.scope.model : ""); const windowMinutes = group === "session" || kind === "session" ? 300 : 10_080; if (stringValue(scopedModel)) { setModelLimit(scopedModel, limit, windowMinutes); } else if (!fiveHour && (kind === "session" || group === "session")) { fiveHour = normalizeWindow(limit, 300); } else if ( !sevenDay && (kind === "weekly_all" || group === "weekly") ) { sevenDay = normalizeWindow(limit, 10_080); } } } if (Array.isArray(data.rate_limits)) { for (const item of data.rate_limits) { if (!item || typeof item !== "object") continue; const windowName = stringValue( item.window_duration ?? item.type ?? item.kind, ).toLowerCase(); const modelName = item.scope?.model?.display_name ?? item.model?.display_name ?? ""; if (stringValue(modelName)) { setModelLimit(modelName, item); } else if (!fiveHour && /5h|five.?hour|session/.test(windowName)) { fiveHour = normalizeWindow(item, 300); } else if (!sevenDay && /7d|seven.?day|week/.test(windowName)) { sevenDay = normalizeWindow(item, 10_080); } } } const result = { fiveHour, sevenDay, modelLimits: [...modelLimits.values()], resetCredits: null, planName: stringValue( data.subscription_type ?? data.plan_name ?? data.plan, ), hasScopedSurface: Array.isArray(data.limits) || modelLimits.size > 0, }; return { ...result, hit: Boolean( result.fiveHour || result.sevenDay || result.modelLimits.length, ), }; } function parseChatGPT(data) { const empty = { fiveHour: null, sevenDay: null, modelLimits: [], resetCredits: null, planName: "", hit: false, }; if (!data || typeof data !== "object") return empty; const rateLimit = data.rate_limit ?? data.rateLimit ?? {}; const primaryWindow = normalizeWindow( rateLimit.primary_window ?? rateLimit.primaryWindow ?? data.primary, 300, ); const secondaryWindow = normalizeWindow( rateLimit.secondary_window ?? rateLimit.secondaryWindow ?? data.secondary, 10_080, ); const weeklyWindow = [primaryWindow, secondaryWindow].find((window) => { const minutes = Number(window?.window_minutes); return Number.isFinite(minutes) && minutes >= 9_360 && minutes <= 10_800; }); const result = { // ChatGPT 网页端只把套餐共享的每周用量作为用户额度展示。 // additional_rate_limits 是内部模型计量项,不属于网页端额度维度。 fiveHour: null, sevenDay: weeklyWindow ?? null, modelLimits: [], resetCredits: parseChatGPTResetCredits(data), planName: stringValue(data.plan_type ?? data.planType ?? data.plan), }; return { ...result, hit: Boolean( result.fiveHour || result.sevenDay || result.modelLimits.length, ), }; } function parseChatGPTResetCredits(data) { if (!data || typeof data !== "object") return null; const summary = data.rate_limit_reset_credits ?? data.rateLimitResetCredits ?? data; if (!summary || typeof summary !== "object") return null; const credits = Array.isArray(summary.credits) ? summary.credits : null; const availableCredits = (credits ?? []).filter((credit) => { if (!credit || typeof credit !== "object") return false; const status = stringValue(credit.status).toLowerCase(); return !status || status === "available"; }); const availableCountValue = firstNumber( summary.available_count, summary.availableCount, ); if (availableCountValue === null && credits === null) return null; const expirations = availableCredits .map((credit) => toTimestampMs(credit.expires_at ?? credit.expiresAt ?? null), ) .filter((timestamp) => timestamp !== null) .sort((a, b) => a - b); return { availableCount: Math.max( 0, Math.floor(availableCountValue ?? availableCredits.length), ), nearestExpiresAt: expirations[0] ?? null, detailsAvailable: credits !== null, }; } function merge(base, incoming) { if (!base) return incoming; const modelLimits = new Map(); for (const item of [...base.modelLimits, ...incoming.modelLimits]) { modelLimits.set(item.name.toLowerCase(), item); } const merged = { fiveHour: incoming.fiveHour ?? base.fiveHour, sevenDay: incoming.sevenDay ?? base.sevenDay, modelLimits: [...modelLimits.values()], resetCredits: incoming.resetCredits ?? base.resetCredits ?? null, planName: incoming.planName || base.planName, hasScopedSurface: Boolean( base.hasScopedSurface || incoming.hasScopedSurface, ), }; return { ...merged, hit: Boolean( merged.fiveHour || merged.sevenDay || merged.modelLimits.length, ), }; } return Object.freeze({ parseClaude, parseChatGPT, parseChatGPTResetCredits, merge, toTimestampMs, }); })(); // END USAGE_PARSERS // BEGIN DYNAMIC_TRANSLATIONS — 处理包含姓名、百分比和日期的运行时文案。 const DynamicTranslations = (() => { const greetings = { morning: "早上好", afternoon: "下午好", evening: "晚上好", }; const months = { jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8, sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12, }; const relativeDays = { today: "今天", tomorrow: "明天", monday: "周一", tuesday: "周二", wednesday: "周三", thursday: "周四", friday: "周五", saturday: "周六", sunday: "周日", }; function formatClock(hourValue, minuteValue, meridiemValue) { let hour = Number(hourValue); const minute = Number(minuteValue || 0); const meridiem = String(meridiemValue || "").toUpperCase(); if (!Number.isFinite(hour) || !Number.isFinite(minute)) return ""; if (meridiem === "AM" && hour === 12) hour = 0; if (meridiem === "PM" && hour < 12) hour += 12; return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; } function formatResetTime(value) { const raw = String(value || "").trim(); if (!raw) return ""; const dateMatch = raw.match( /^([A-Za-z]+)\s+(\d{1,2})(?:,\s*(\d{4}))?(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(AM|PM))?$/i, ); if (dateMatch) { const month = months[dateMatch[1].toLowerCase()]; if (month) { const year = dateMatch[3] ? `${dateMatch[3]}年` : ""; const clock = dateMatch[4] ? ` ${formatClock(dateMatch[4], dateMatch[5], dateMatch[6])}` : ""; return `${year}${month}月${Number(dateMatch[2])}日${clock}`; } } const relativeMatch = raw.match( /^(Today|Tomorrow|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(AM|PM)$/i, ); if (relativeMatch) { return `${relativeDays[relativeMatch[1].toLowerCase()]} ${formatClock( relativeMatch[2], relativeMatch[3], relativeMatch[4], )}`; } return raw .replace(/\bat\b/gi, "") .replace(/\bAM\b/gi, "上午") .replace(/\bPM\b/gi, "下午") .replace(/\s{2,}/g, " ") .trim(); } function formatLimitName(value) { const raw = String(value || "").trim(); const known = { usage: "通用", weekly: "每周", session: "会话", "extra usage": "额外用量", }; return known[raw.toLowerCase()] || raw; } // 模型选择器与工作量(effort)菜单的英文词条:主翻译词库锁在 v1.0.0 tag, // 新 UI 词条在这里增补。整节点精确匹配后替换,浮窗自身在 Shadow DOM 内不受影响。 const staticPhrases = { "For your toughest challenges": "应对最棘手的挑战", "For complex tasks": "适合复杂任务", "Most efficient for everyday tasks": "日常任务最高效", "Fastest for quick answers": "快速问答最迅捷", "Higher effort means more thorough responses, but takes longer and uses your limits faster.": "工作量越高,回答越详尽,但耗时更长、额度消耗也更快。", "More models": "更多模型", Effort: "工作量", // 工作量档位(Low/Medium/High/Extra/Max)保留英文:产品语境下的 // 强度词,中文直译反而生硬(2026-07-14 用户反馈)。 Default: "默认", }; function translate(value) { const original = String(value || "").trim(); const text = original .replace(/[\u200B-\u200D\u2060\uFEFF]/g, "") .replace(/\u00A0/g, " ") .replace(/[ \t]{2,}/g, " "); if (!text) return text; if (Object.prototype.hasOwnProperty.call(staticPhrases, text)) { return staticPhrases[text]; } const includedMatch = text.match( /^Included until\s+([A-Za-z]+\s+\d{1,2}(?:,\s*\d{4})?)$/i, ); if (includedMatch) { return `${formatResetTime(includedMatch[1])}前可用`; } const greetingMatch = text.match( /^(?:Good\s+)?(Morning|Afternoon|Evening),\s*(.*)$/i, ); if (greetingMatch) { const greeting = greetings[greetingMatch[1].toLowerCase()]; return `${greeting},${greetingMatch[2]}`; } const usageMatch = text.match( /^You(?:'|’)ve used\s*(\d+(?:\.\d+)?\s*%)\s*of\s+your\s*(.+?)\s+limit(?:\s*[·∙•]\s*Resets\s+(.+))?$/i, ); if (usageMatch) { const percent = usageMatch[1].replace(/\s+/g, ""); const limitName = formatLimitName(usageMatch[2]); const reset = usageMatch[3] ? ` · 将于 ${formatResetTime(usageMatch[3])} 重置` : ""; const usagePrefix = /[\u3400-\u9fff]/u.test(limitName) ? `您已使用${limitName}额度的` : `您已使用 ${limitName} 额度的`; return `${usagePrefix} ${percent}${reset}`; } return original; } function translateSegments(values) { const original = values.map((value) => String(value || "")).join(""); const translated = translate(original); return translated !== original.trim() ? translated : null; } return Object.freeze({ formatResetTime, translate, translateSegments }); })(); // END DYNAMIC_TRANSLATIONS const ClaudeUsageWidget = (() => { "use strict"; const provider = isChatGPTSite ? "chatgpt" : "claude"; const panelTitle = provider === "chatgpt" ? "ChatGPT 使用限制" : "Claude 用量监控"; const positionStorageKey = provider === "chatgpt" ? "claude2cn-chatgpt-usage-position" : "claude-usage-position"; let orgId = null; let autoRefreshTimer = null; let refreshInterval = null; let countdownTimer = null; let isHovered = false; let panel = null; let claudeShadow = null; let chatgptShadow = null; let claudeWidgetState = "collapsed"; let claudeAutoCollapseTimer = null; let claudeDocumentClickHandler = null; let claudeKeyHandler = null; let isDragging = false; // ChatGPT 浮窗定位:水平永远吸附左/右边缘(与 Claude 的贴边一致), // 拖动只保留垂直位置与停靠边,不再记忆任意悬空坐标。 let savedPosition = { top: 50, isRight: true }; const claudeSettingsStorageKey = "claude-usage-monitor:settings:v1"; const defaultClaudeSettings = Object.freeze({ autoCollapse: true, autoCollapseDelay: 4000, showResetTime: true, verticalPosition: "top", lastVisibleState: "collapsed", }); let claudeSettings = { ...defaultClaudeSettings }; let usageData = { provider, fiveHour: null, sevenDay: null, modelLimits: [], resetCredits: null, planName: "", lastFetch: null, fetchError: null, }; const _origFetch = window.fetch.bind(window); function hookFetch() { window.fetch = function (...args) { const url = typeof args[0] === "string" ? args[0] : args[0] instanceof Request ? args[0].url : ""; captureOrgId(url); return _origFetch(...args); }; const _origXHROpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url, ...rest) { if (typeof url === "string") captureOrgId(url); return _origXHROpen.call(this, method, url, ...rest); }; } function captureOrgId(url) { if (!url) return; const m = url.match( /\/api\/organizations\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i, ); if (!m) return; const newId = m[1]; if (orgId !== newId) { orgId = newId; console.log("[Claude用量] orgId 已获取:", orgId); } if (autoRefreshTimer) clearTimeout(autoRefreshTimer); autoRefreshTimer = setTimeout(fetchUsage, 600); } async function discoverOrgId() { if (provider !== "claude") return true; if (orgId) return true; const candidates = [ "https://claude.ai/api/bootstrap", // "https://claude.ai/api/organizations", ]; for (const url of candidates) { try { const res = await _origFetch(url, { credentials: "include", headers: { Accept: "application/json" }, }); if (!res.ok) continue; const data = await res.json(); const str = JSON.stringify(data); const m = str.match( /"(?:uuid|id|organization_id)"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/i, ); if (m && !orgId) { orgId = m[1]; console.log(`[Claude用量] 从 ${url} 获取 orgId`, orgId); return true; } } catch {} } return false; } function createPanel() { if (provider === "claude") return createClaudePanel(); return createChatGPTPanel(); } function createChatGPTPanel() { const host = document.createElement("div"); host.id = "claude-usage-panel-bottom"; host.setAttribute("data-chatgpt-usage-widget", "v3"); host.title = panelTitle; chatgptShadow = host.attachShadow({ mode: "open" }); chatgptShadow.innerHTML = `
${claudeIcon("boltFilled")}ChatGPT 用量
正在获取额度…
`; panel = host; const compact = chatgptShadow.querySelector('[data-action="expand"]'); compact.addEventListener("click", () => setChatGPTWidgetState(true)); applyChatGPTPosition(); return host; } // 水平永远贴边(左或右 8px),只有垂直位置与停靠边可调,与 Claude 的贴边一致。 function applyChatGPTPosition(host = panel) { if (!host) return; const margin = getPanelMetrics().defaultRight; const maxTop = Math.max( margin, window.innerHeight - (host.offsetHeight || 120) - margin, ); const top = Math.min( Math.max(margin, Number(savedPosition.top) || 50), maxTop, ); host.style.top = top + "px"; host.style.bottom = "auto"; host.setAttribute("data-dock", savedPosition.isRight ? "right" : "left"); if (savedPosition.isRight) { host.style.right = margin + "px"; host.style.left = "auto"; } else { host.style.left = margin + "px"; host.style.right = "auto"; } } function setChatGPTWidgetState(expanded) { if (!chatgptShadow || !panel) return; if (Boolean(expanded) === isHovered) return; isHovered = Boolean(expanded); const widget = chatgptShadow.querySelector(".usage-widget"); widget.dataset.state = isHovered ? "expanded" : "collapsed"; chatgptShadow .querySelector(".compact-card") .classList.toggle("is-off", isHovered); chatgptShadow .querySelector(".expanded-card") .classList.toggle("is-off", !isHovered); } function loadClaudeSettings() { try { const saved = JSON.parse( localStorage.getItem(claudeSettingsStorageKey) || "{}", ); const delay = [2000, 4000, 8000].includes(saved.autoCollapseDelay) ? saved.autoCollapseDelay : defaultClaudeSettings.autoCollapseDelay; const verticalPosition = ["top", "center", "bottom"].includes( saved.verticalPosition, ) ? saved.verticalPosition : defaultClaudeSettings.verticalPosition; const lastVisibleState = ["collapsed", "expanded"].includes( saved.lastVisibleState, ) ? saved.lastVisibleState : defaultClaudeSettings.lastVisibleState; return { autoCollapse: typeof saved.autoCollapse === "boolean" ? saved.autoCollapse : defaultClaudeSettings.autoCollapse, autoCollapseDelay: delay, showResetTime: typeof saved.showResetTime === "boolean" ? saved.showResetTime : defaultClaudeSettings.showResetTime, verticalPosition, lastVisibleState, }; } catch { return { ...defaultClaudeSettings }; } } function saveClaudeSettings() { try { localStorage.setItem( claudeSettingsStorageKey, JSON.stringify(claudeSettings), ); } catch {} } function claudeIcon(name) { // Tabler Icons 风格的内嵌线性图标;不依赖外部 CDN。 const paths = { bolt: '', boltFilled: '', clock: '', calendar: '', sparkles: '', ticket: '', close: '', refresh: '', settings: '', }; return ``; } function generatedClaudeIcon(name, className = "generated-icon") { const source = globalThis.CLAUDE_USAGE_ICON_ASSETS?.[name]; return typeof source === "string" ? `` : ""; } const claudeQuotaIcons = { fiveHour: "clock", sevenDay: "calendar", fableFive: "sparkles", model: "sparkles", }; // Claude 与 ChatGPT 浮窗共用的设计语言:变量、明暗主题、收起/展开卡片、 // 额度条目、过渡动画。各自的定位与专属控件在 create*Panel 里追加。 function widgetSharedStyles() { return ` :host { --cu-font: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; --cu-bg: rgba(255, 255, 255, 0.96); --cu-bg-soft: rgba(32, 33, 36, 0.05); --cu-text: #1f2124; --cu-text-secondary: #6d7176; --cu-text-tertiary: #989ba1; --cu-border: rgba(32, 33, 36, 0.08); --cu-divider: rgba(32, 33, 36, 0.06); --cu-shadow: 0 10px 28px rgba(31, 35, 41, 0.10), 0 1px 3px rgba(31, 35, 41, 0.05); --cu-danger: #ef493d; --cu-transition: 200ms cubic-bezier(0.2, 0.8, 0.2, 1); position: fixed; z-index: 2147483000; color: var(--cu-text); font-family: var(--cu-font); font-size: 13px; line-height: 1.4; color-scheme: light; user-select: none; } :host([data-theme="dark"]) { --cu-bg: rgba(38, 39, 42, 0.96); --cu-bg-soft: rgba(255, 255, 255, 0.065); --cu-text: #f2f3f5; --cu-text-secondary: #b5b8bd; --cu-text-tertiary: #8f9399; --cu-border: rgba(255, 255, 255, 0.10); --cu-divider: rgba(255, 255, 255, 0.07); --cu-shadow: 0 10px 30px rgba(0, 0, 0, 0.38), 0 1px 3px rgba(0, 0, 0, 0.25); color-scheme: dark; } *, *::before, *::after { box-sizing: border-box; } button, select, input { font: inherit; } button { color: inherit; } [hidden] { display: none !important; } svg { width: 18px; height: 18px; display: block; } img { display: block; } .generated-icon { width: 18px; height: 18px; object-fit: contain; } .usage-widget { position: relative; display: flex; justify-content: flex-end; } /* 收起/展开互斥卡片:离场卡绝对定位叠在原地做淡出,在场卡撑起浮窗尺寸 */ .compact-card, .expanded-card { transform-origin: top right; transition: opacity var(--cu-transition), transform var(--cu-transition), box-shadow var(--cu-transition), visibility 0s linear 0s; } .compact-card.is-off, .expanded-card.is-off { position: absolute; top: 0; right: 0; opacity: 0; visibility: hidden; pointer-events: none; transform: scale(0.96) translateY(-6px); transition: opacity var(--cu-transition), transform var(--cu-transition), visibility 0s linear 200ms; } .compact-card { width: 96px; padding: 7px; display: grid; gap: 5px; border: 1px solid var(--cu-border); border-radius: 13px; background: var(--cu-bg); box-shadow: var(--cu-shadow); backdrop-filter: blur(12px) saturate(1.05); -webkit-backdrop-filter: blur(12px) saturate(1.05); cursor: pointer; } .compact-card:hover { transform: translateY(-1px); } .compact-card:focus-visible, .icon-button:focus-visible, .setting-control:focus-visible, .reset-settings:focus-visible { outline: 2px solid #4285f4; outline-offset: 2px; } .compact-list { display: grid; gap: 5px; } .compact-row { min-height: 30px; padding: 0 9px; display: grid; grid-template-columns: minmax(0, 1fr) 40px; align-items: center; gap: 5px; border-radius: 9px; background: var(--cu-bg-soft); } .compact-row[data-danger] { background: var(--quota-soft, var(--cu-bg-soft)); } .compact-label { min-width: 0; justify-self: end; color: var(--cu-text-secondary); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; } .compact-percent { width: 40px; justify-self: end; color: var(--quota-color); font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; } .compact-status { min-height: 30px; display: grid; place-items: center; color: var(--cu-text-secondary); font-size: 12px; } .expanded-card { width: min(304px, calc(100vw - 24px)); max-height: calc(100vh - 32px); display: flex; overflow: hidden; flex-direction: column; border: 1px solid var(--cu-border); border-radius: 16px; background: var(--cu-bg); box-shadow: var(--cu-shadow); backdrop-filter: blur(12px) saturate(1.05); -webkit-backdrop-filter: blur(12px) saturate(1.05); } .widget-header { flex: 0 0 auto; min-height: 48px; padding: 0 11px 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cu-divider); } .widget-title { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; } .title-badge { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 6px; color: #fff; } .title-badge svg { width: 12px; height: 12px; } .title-badge.generated { background: transparent !important; } .title-badge .generated-title-icon { width: 20px; height: 20px; object-fit: contain; } .icon-button { width: 32px; height: 32px; padding: 7px; display: grid; place-items: center; border: 0; border-radius: 9px; background: transparent; color: var(--cu-text-secondary); cursor: pointer; } .icon-button:hover { background: var(--cu-bg-soft); color: var(--cu-text); } .quota-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; color: var(--cu-text-secondary); background: var(--cu-bg-soft); white-space: nowrap; } .quota-list { flex: 1 1 auto; overflow-y: auto; padding: 3px 0 4px; } .quota-item { padding: 11px 14px 12px; } .quota-meta { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; column-gap: 9px; margin-bottom: 9px; } .quota-icon { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 7px; background: var(--quota-soft); background: color-mix(in srgb, var(--quota-color) 13%, transparent); color: var(--quota-color); } :host([data-theme="dark"]) .quota-icon { background: var(--quota-soft); background: color-mix(in srgb, var(--quota-color) 22%, transparent); } .quota-icon svg { width: 14px; height: 14px; stroke-width: 2; } .quota-icon.generated { background: transparent !important; } .quota-icon .generated-quota-icon { width: 24px; height: 24px; object-fit: contain; } .quota-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cu-text); font-size: 13px; font-weight: 550; } .quota-remaining { color: var(--cu-text-tertiary); font-size: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; } .quota-value-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 14px; } .quota-track { height: 6px; overflow: hidden; border-radius: 999px; background: var(--quota-soft); background: color-mix(in srgb, var(--quota-color) 15%, transparent); } :host([data-theme="dark"]) .quota-track { background: var(--quota-soft); background: color-mix(in srgb, var(--quota-color) 24%, transparent); } .quota-fill { width: var(--remaining-percent); height: 100%; border-radius: inherit; background: var(--quota-color); transition: width 300ms ease; } .quota-percent { min-width: 52px; text-align: right; color: var(--quota-color); font-size: 22px; line-height: 1; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; } .expanded-status { min-height: 132px; display: grid; place-items: center; padding: 24px; color: var(--cu-text-secondary); text-align: center; } .widget-footer { flex: 0 0 auto; min-height: 44px; padding: 0 10px 0 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-top: 1px solid var(--cu-divider); color: var(--cu-text-secondary); } .reset-time { min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; white-space: nowrap; } .reset-time svg { width: 14px; height: 14px; flex: 0 0 auto; color: var(--cu-text-tertiary); } .reset-time .generated-icon { width: 18px; height: 18px; flex: 0 0 auto; } .reset-time time { font-variant-numeric: tabular-nums; color: var(--cu-text-secondary); } .usage-tooltip { position: absolute; right: calc(100% + 8px); z-index: 3; width: max-content; max-width: 250px; padding: 8px 10px; border: 1px solid var(--cu-border); border-radius: 9px; background: var(--cu-bg); box-shadow: var(--cu-shadow); color: var(--cu-text-secondary); font-size: 11px; line-height: 1.55; pointer-events: none; white-space: normal; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; } } `; } function createClaudePanel() { claudeSettings = loadClaudeSettings(); // 窄视口(<900px)一律从收起态开始,避免展开卡遮挡正文。 claudeWidgetState = window.innerWidth < 900 ? "collapsed" : claudeSettings.lastVisibleState; const host = document.createElement("div"); host.id = "claude-usage-panel-bottom"; host.setAttribute("data-claude-usage-widget", "v2"); claudeShadow = host.attachShadow({ mode: "open" }); claudeShadow.innerHTML = `
${generatedClaudeIcon("bolt", "generated-title-icon")}Claude 用量
正在获取额度…
${generatedClaudeIcon("refresh")}重置时间:

浮窗设置

`; panel = host; bindClaudePanelEvents(host); applyClaudePosition(host); setClaudeWidgetState(claudeWidgetState, false); return host; } function applyClaudePosition(host = panel) { if (!host) return; host.style.left = "auto"; host.style.right = "12px"; host.style.top = "auto"; host.style.bottom = "auto"; host.style.transform = "none"; // data-anchor 决定离场卡片的对齐边与缩放方向(底部锚定时向上展开)。 host.setAttribute("data-anchor", claudeSettings.verticalPosition === "bottom" ? "bottom" : "top"); if (claudeSettings.verticalPosition === "center") { host.style.top = "50%"; host.style.transform = "translateY(-50%)"; } else if (claudeSettings.verticalPosition === "bottom") { host.style.bottom = "24px"; } else { host.style.top = "96px"; } } function clearClaudeAutoCollapse() { if (claudeAutoCollapseTimer) clearTimeout(claudeAutoCollapseTimer); claudeAutoCollapseTimer = null; } function scheduleClaudeAutoCollapse() { clearClaudeAutoCollapse(); if ( !claudeSettings.autoCollapse || claudeWidgetState !== "expanded" ) return; claudeAutoCollapseTimer = setTimeout( () => setClaudeWidgetState("collapsed"), claudeSettings.autoCollapseDelay, ); } function setClaudeWidgetState(nextState, persist = true) { if (!claudeShadow || !panel) return; const allowed = ["collapsed", "expanded", "settings", "hidden"]; const next = allowed.includes(nextState) ? nextState : "collapsed"; claudeWidgetState = next; clearClaudeAutoCollapse(); const widget = claudeShadow.querySelector(".usage-widget"); const compact = claudeShadow.querySelector(".compact-card"); const expanded = claudeShadow.querySelector(".expanded-card"); const settings = claudeShadow.querySelector(".settings-popover"); widget.dataset.state = next; panel.style.display = next === "hidden" ? "none" : ""; // is-off 通过 opacity/transform/visibility 过渡离场,替代 hidden 的瞬间切换。 compact.classList.toggle("is-off", next !== "collapsed"); expanded.classList.toggle("is-off", !["expanded", "settings"].includes(next)); settings.classList.toggle("is-off", next !== "settings"); if (["collapsed", "expanded"].includes(next)) { claudeSettings.lastVisibleState = next; } else if (next === "settings") { claudeSettings.lastVisibleState = "expanded"; } if (persist) saveClaudeSettings(); } function updateClaudeSettingsControls() { if (!claudeShadow) return; claudeShadow.querySelector('[data-setting="autoCollapse"]').checked = claudeSettings.autoCollapse; claudeShadow.querySelector( '[data-setting="autoCollapseDelay"]', ).value = String(claudeSettings.autoCollapseDelay); claudeShadow.querySelector('[data-setting="showResetTime"]').checked = claudeSettings.showResetTime; claudeShadow.querySelector( '[data-setting="verticalPosition"]', ).value = claudeSettings.verticalPosition; } function registerClaudeMenuCommands() { const register = typeof globalThis.GM_registerMenuCommand === "function" ? globalThis.GM_registerMenuCommand : typeof globalThis.GM?.registerMenuCommand === "function" ? globalThis.GM.registerMenuCommand.bind(globalThis.GM) : null; if (!register) return; register("显示 Claude 用量监控", () => setClaudeWidgetState("collapsed"), ); register("隐藏 Claude 用量监控", () => setClaudeWidgetState("hidden"), ); register("恢复用量浮窗默认设置", () => { claudeSettings = { ...defaultClaudeSettings }; saveClaudeSettings(); applyClaudePosition(); updateClaudeSettingsControls(); setClaudeWidgetState("collapsed"); renderClaudePanel(); }); } function bindClaudePanelEvents(host) { const compact = claudeShadow.querySelector('[data-action="expand"]'); const header = claudeShadow.querySelector(".widget-header"); compact.addEventListener("mouseenter", () => setClaudeWidgetState("expanded"), ); compact.addEventListener("click", () => setClaudeWidgetState("expanded"), ); header.addEventListener("click", (event) => { if (event.target instanceof Element && event.target.closest("button")) return; setClaudeWidgetState("collapsed"); }); claudeShadow .querySelector('[data-action="hide"]') .addEventListener("click", () => setClaudeWidgetState("hidden")); claudeShadow .querySelector('[data-action="settings"]') .addEventListener("click", () => setClaudeWidgetState("settings")); claudeShadow .querySelector('[data-action="reset-settings"]') .addEventListener("click", () => { claudeSettings = { ...defaultClaudeSettings }; saveClaudeSettings(); applyClaudePosition(host); updateClaudeSettingsControls(); setClaudeWidgetState("expanded"); renderClaudePanel(); }); claudeShadow .querySelector('[data-setting="autoCollapse"]') .addEventListener("change", (event) => { claudeSettings.autoCollapse = event.target.checked; saveClaudeSettings(); }); claudeShadow .querySelector('[data-setting="autoCollapseDelay"]') .addEventListener("change", (event) => { claudeSettings.autoCollapseDelay = Number(event.target.value); saveClaudeSettings(); }); claudeShadow .querySelector('[data-setting="showResetTime"]') .addEventListener("change", (event) => { claudeSettings.showResetTime = event.target.checked; saveClaudeSettings(); renderClaudePanel(); }); claudeShadow .querySelector('[data-setting="verticalPosition"]') .addEventListener("change", (event) => { claudeSettings.verticalPosition = event.target.value; saveClaudeSettings(); applyClaudePosition(host); }); host.addEventListener("mouseenter", clearClaudeAutoCollapse); host.addEventListener("mouseleave", scheduleClaudeAutoCollapse); claudeDocumentClickHandler = (event) => { if (claudeWidgetState === "hidden" || host.contains(event.target)) return; if (claudeWidgetState === "settings") { setClaudeWidgetState("expanded"); } else if (claudeWidgetState === "expanded") { setClaudeWidgetState("collapsed"); } }; claudeKeyHandler = (event) => { if (event.altKey && event.shiftKey && event.key.toLowerCase() === "u") { setClaudeWidgetState("collapsed"); return; } if (event.key !== "Escape") return; if (claudeWidgetState === "settings") { setClaudeWidgetState("expanded"); } else if (claudeWidgetState === "expanded") { setClaudeWidgetState("collapsed"); } }; document.addEventListener("click", claudeDocumentClickHandler); document.addEventListener("keydown", claudeKeyHandler); updateClaudeSettingsControls(); registerClaudeMenuCommands(); } // 剩余额度四档配色:直接以 remaining 判断,避免 46% 等中低额度 // 因“已用量 < 60%”而仍显示绿色。额度类型继续由条目图标区分。 function quotaHealthColors(remaining) { const value = Math.max(0, Math.min(100, Number(remaining) || 0)); if (value >= 80) return ["#18b96b", "rgba(24, 185, 107, 0.14)"]; if (value >= 60) return ["#4285f4", "rgba(66, 133, 244, 0.14)"]; if (value >= 40) return ["#ff6b3d", "rgba(255, 107, 61, 0.14)"]; return ["#ef493d", "rgba(239, 73, 61, 0.14)"]; } function getClaudeViewRows() { return getUsageRows() .filter( (row) => row.key === "primary" || row.key === "secondary" || /^Fable 5$/i.test(row.title || ""), ) .map((row) => { const used = pct(row.utilization); const remaining = 100 - used; const isFable = /^Fable 5$/i.test(row.title || ""); const type = row.key === "primary" ? "fiveHour" : row.key === "secondary" ? "sevenDay" : isFable ? "fableFive" : "model"; const shortLabel = type === "fiveHour" ? "5h" : type === "sevenDay" ? "7d" : type === "fableFive" ? "F5" : row.short; const fullLabel = type === "fiveHour" ? "5 小时窗口" : type === "sevenDay" ? "7 天配额" : type === "fableFive" ? "Fable 5 · 7 天配额" : row.label; const [color, softColor] = quotaHealthColors(remaining); const assetIconName = { fiveHour: "clock", sevenDay: "calendar", fableFive: "brain", model: "brain", }[type]; const countdown = cdText(row.resets_at); return { ...row, type, shortLabel, fullLabel, assetIconName, remaining, critical: remaining <= 10, remainingText: countdown ? `${countdown} 剩余` : "剩余时间待定", resetText: fmtExpiryTime(row.resets_at), color, softColor, }; }); } function showWidgetTooltip(shadow, element) { const tooltip = shadow?.querySelector(".usage-tooltip"); if (!tooltip || !element?.dataset.tooltip) return; tooltip.textContent = element.dataset.tooltip; tooltip.style.top = `${element.offsetTop}px`; tooltip.hidden = false; } function hideWidgetTooltip(shadow) { const tooltip = shadow?.querySelector(".usage-tooltip"); if (tooltip) tooltip.hidden = true; } // Claude / ChatGPT 浮窗共用的额度节点增量更新:只改既有 DOM,不重建根。 function updateQuotaNodes(shadow, rows, { withTooltip = false } = {}) { const compactList = shadow.querySelector(".compact-list"); const quotaList = shadow.querySelector(".quota-list"); const activeKeys = new Set(rows.map((row) => row.key)); for (const element of [ ...compactList.querySelectorAll("[data-quota-key]"), ...quotaList.querySelectorAll("[data-quota-key]"), ]) { if (!activeKeys.has(element.dataset.quotaKey)) element.remove(); } for (const row of rows) { let compactRow = compactList.querySelector( `[data-quota-key="${row.key}"]`, ); if (!compactRow) { compactRow = document.createElement("span"); compactRow.className = "compact-row"; compactRow.dataset.quotaKey = row.key; compactRow.innerHTML = ''; if (withTooltip) { compactRow.addEventListener("mouseenter", () => showWidgetTooltip(shadow, compactRow), ); compactRow.addEventListener("mouseleave", () => hideWidgetTooltip(shadow), ); } compactList.appendChild(compactRow); } compactRow.style.setProperty("--quota-color", row.color); compactRow.style.setProperty("--quota-soft", row.softColor); compactRow.toggleAttribute("data-danger", row.critical); compactRow.querySelector(".compact-label").textContent = row.shortLabel; compactRow.querySelector(".compact-percent").textContent = `${row.remaining}%`; compactRow.dataset.tooltip = `${row.fullLabel} · ${row.remainingText} · ${row.resetText} 重置`; compactRow.title = compactRow.dataset.tooltip; compactList.appendChild(compactRow); let quotaItem = quotaList.querySelector( `[data-quota-key="${row.key}"]`, ); if (!quotaItem) { quotaItem = document.createElement("article"); quotaItem.className = "quota-item"; quotaItem.dataset.quotaKey = row.key; const usesGeneratedIcon = Boolean(row.assetIconName); const quotaIcon = usesGeneratedIcon ? generatedClaudeIcon(row.assetIconName, "generated-quota-icon") : claudeIcon(row.iconName || claudeQuotaIcons[row.type] || "sparkles"); quotaItem.innerHTML = `
`; quotaList.appendChild(quotaItem); } quotaItem.style.setProperty("--quota-color", row.color); quotaItem.style.setProperty("--quota-soft", row.softColor); quotaItem.style.setProperty( "--remaining-percent", `${row.remaining}%`, ); quotaItem.setAttribute( "aria-label", `${row.fullLabel}剩余 ${row.remaining}%`, ); quotaItem.querySelector(".quota-name").textContent = row.fullLabel; quotaItem.querySelector(".quota-remaining").textContent = row.remainingText; quotaItem.querySelector(".quota-percent").textContent = `${row.remaining}%`; quotaList.appendChild(quotaItem); } } function renderClaudePanel() { if (!claudeShadow || !panel) return; const rows = getClaudeViewRows(); const compactList = claudeShadow.querySelector(".compact-list"); const compactStatus = claudeShadow.querySelector(".compact-status"); const quotaList = claudeShadow.querySelector(".quota-list"); const expandedStatus = claudeShadow.querySelector(".expanded-status"); const statusText = usageData.fetchError ? usageData.fetchError : "正在获取额度…"; if (rows.length) { updateQuotaNodes(claudeShadow, rows, { withTooltip: true }); compactList.hidden = false; compactStatus.hidden = true; quotaList.hidden = false; expandedStatus.hidden = true; } else { compactList.hidden = true; compactStatus.hidden = false; compactStatus.textContent = usageData.fetchError ? "获取失败" : "正在获取…"; compactStatus.title = statusText; quotaList.hidden = true; expandedStatus.hidden = false; expandedStatus.textContent = statusText; } const resetAt = usageData.sevenDay?.resets_at ?? usageData.fiveHour?.resets_at ?? rows[0]?.resets_at ?? null; const resetTime = claudeShadow.querySelector(".reset-time"); resetTime.hidden = !claudeSettings.showResetTime; resetTime.querySelector("time").textContent = fmtExpiryTime(resetAt); updateClaudeSettingsControls(); applyClaudePosition(); } function getChatGPTViewRows() { return getUsageRows().map((row) => { const used = pct(row.utilization); const remaining = 100 - used; const isWeekly = row.key === "primary" || row.key === "secondary"; const type = isWeekly ? "weekly" : "model"; const [color, softColor] = quotaHealthColors(remaining); const countdown = cdText(row.resets_at); return { ...row, type, iconName: isWeekly ? "calendar" : "sparkles", shortLabel: isWeekly ? row.short || "7d" : row.short, fullLabel: isWeekly ? "每周使用限额" : row.title || row.label, remaining, critical: remaining <= 10, remainingText: countdown ? `${countdown} 剩余` : "剩余时间待定", resetText: fmtExpiryTime(row.resets_at), color, softColor, }; }); } // 收起态的重置卡行:额度行之下追加「重置 N」,次数与展开态票券同紫。 function renderChatGPTCompactCredits() { const compactList = chatgptShadow.querySelector(".compact-list"); const credits = usageData.resetCredits; let row = compactList.querySelector("[data-credit-row]"); if (!credits) { row?.remove(); return; } if (!row) { row = document.createElement("span"); row.className = "compact-row"; row.setAttribute("data-credit-row", ""); // label 用票券小图标而非「重置」文字:与 5h/7d 缩写列同宽, // 保持收起卡两列网格的秩序感(2026-07-14 用户反馈)。 row.innerHTML = ``; } row.style.setProperty( "--quota-color", credits.availableCount > 0 ? "#8b5cf6" : "var(--cu-text-tertiary)", ); row.querySelector(".compact-percent").textContent = `×${credits.availableCount}`; row.title = `重置卡 ${credits.availableCount} 次可用 · 最近到期 ${fmtExpiryTime( credits.nearestExpiresAt, )}`; // 额度行每次刷新会重新 append,这里同样移到末尾保持行序稳定。 compactList.appendChild(row); } function renderChatGPTCredits() { const creditList = chatgptShadow.querySelector(".credit-list"); const credits = usageData.resetCredits; if (!credits) { creditList.hidden = true; creditList.innerHTML = ""; return; } let item = creditList.querySelector(".credit-item"); if (!item) { creditList.innerHTML = `
重置卡
最近到期
`; item = creditList.querySelector(".credit-item"); item.style.setProperty("--quota-color", "#8b5cf6"); item.style.setProperty("--quota-soft", "rgba(139, 92, 246, 0.12)"); } item.querySelector(".credit-count").textContent = `${credits.availableCount} 次可用`; item.querySelector(".credit-expiry").textContent = fmtExpiryTime( credits.nearestExpiresAt, ); creditList.hidden = false; } function renderChatGPTPanel() { if (!chatgptShadow || !panel) return; const rows = getChatGPTViewRows(); const compactList = chatgptShadow.querySelector(".compact-list"); const compactStatus = chatgptShadow.querySelector(".compact-status"); const quotaList = chatgptShadow.querySelector(".quota-list"); const expandedStatus = chatgptShadow.querySelector(".expanded-status"); const statusText = usageData.fetchError ? usageData.fetchError : "正在获取额度…"; if (rows.length) { updateQuotaNodes(chatgptShadow, rows); renderChatGPTCompactCredits(); compactList.hidden = false; compactStatus.hidden = true; quotaList.hidden = false; expandedStatus.hidden = true; } else { compactList.hidden = true; compactStatus.hidden = false; compactStatus.textContent = usageData.fetchError ? "获取失败" : "正在获取…"; compactStatus.title = statusText; quotaList.hidden = true; expandedStatus.hidden = false; expandedStatus.textContent = statusText; } renderChatGPTCredits(); const planBadge = chatgptShadow.querySelector(".plan-badge"); const planName = formatPlanName(usageData.planName); planBadge.hidden = !planName; planBadge.textContent = planName; planBadge.title = planName; const resetAt = usageData.sevenDay?.resets_at ?? usageData.fiveHour?.resets_at ?? rows[0]?.resets_at ?? null; chatgptShadow.querySelector(".reset-time time").textContent = fmtExpiryTime(resetAt); } function applyTheme() { if (!panel) return; const isDark = document.documentElement.classList.contains("dark") || document.documentElement.getAttribute("data-theme") === "dark" || window.matchMedia("(prefers-color-scheme: dark)").matches; panel.setAttribute("data-theme", isDark ? "dark" : "light"); } function pct(v) { return Math.min(100, Math.max(0, Math.round(v || 0))); } function cdText(ts) { const target = UsageParsers.toTimestampMs(ts); if (target === null) return ""; const diff = target - Date.now(); if (diff <= 0) return "已重置"; const h = Math.floor(diff / 3600000); const m = Math.floor((diff % 3600000) / 60000); return h > 0 ? `${h}h ${m}m` : `${m}m`; } function fmtExpiryTime(ts) { const timestamp = UsageParsers.toTimestampMs(ts); if (timestamp === null) return "暂无到期时间"; const date = new Date(timestamp); if (isNaN(date.getTime())) return "暂无到期时间"; const pad = (value) => String(value).padStart(2, "0"); const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; return `${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${weekdays[date.getDay()]} ${pad(date.getHours())}:${pad(date.getMinutes())}`; } function durationLabels(window, fallbackLabel, fallbackShort) { const minutes = Number(window?.window_minutes); if (!Number.isFinite(minutes) || minutes <= 0) { return { label: fallbackLabel, short: fallbackShort }; } if (minutes % 1440 === 0) { const days = minutes / 1440; return { label: `${days}天配额`, short: `${days}d` }; } if (minutes % 60 === 0) { const hours = minutes / 60; return { label: `${hours}小时窗口`, short: `${hours}h` }; } return { label: `${Math.round(minutes)}分钟窗口`, short: `${Math.round(minutes)}m` }; } function compactDurationLabel(label) { return String(label || "").replace(/(?:配额|窗口)$/u, ""); } function formatPlanName(value) { const raw = String(value || "").trim(); if (!raw) return ""; const normalized = raw.toLowerCase().replace(/[\s_-]+/g, ""); const knownPlans = { free: "Free", plus: "Plus", pro: "Pro", prolite: "Pro Lite", team: "Team", business: "Business", enterprise: "Enterprise", edu: "Edu", }; if (knownPlans[normalized]) return knownPlans[normalized]; return raw .replace(/[_-]+/g, " ") .replace(/\b[a-z]/g, (letter) => letter.toUpperCase()); } function splitLegacyModelName(item) { const explicitName = String(item?.modelName || "").trim(); const explicitWindow = String(item?.windowLabel || "").trim(); if (explicitName) { return { modelName: explicitName, windowLabel: explicitWindow }; } const legacyName = String(item?.name || "模型").trim(); const match = legacyName.match(/^(.*?)\s*·\s*(主窗口|次窗口)$/u); return match ? { modelName: match[1] || "模型", windowLabel: match[2] } : { modelName: legacyName || "模型", windowLabel: explicitWindow }; } function getUsageRows() { const rows = []; if (usageData.fiveHour) { const labels = durationLabels( usageData.fiveHour, "5小时窗口", "5h", ); const chatGPTTitle = "每周使用限额"; rows.push({ key: "primary", icon: "⚡", ...labels, title: provider === "chatgpt" ? chatGPTTitle : labels.label, meta: provider === "chatgpt" ? compactDurationLabel(labels.label) : "", label: provider === "chatgpt" ? `${chatGPTTitle} · ${compactDurationLabel(labels.label)}` : labels.label, short: labels.short, ...usageData.fiveHour, }); } if (usageData.sevenDay) { const labels = durationLabels( usageData.sevenDay, "7天配额", "7d", ); const chatGPTTitle = "每周使用限额"; rows.push({ key: "secondary", icon: "📅", ...labels, title: provider === "chatgpt" ? chatGPTTitle : labels.label, meta: provider === "chatgpt" ? compactDurationLabel(labels.label) : "", label: provider === "chatgpt" ? `${chatGPTTitle} · ${compactDurationLabel(labels.label)}` : labels.label, short: labels.short, ...usageData.sevenDay, }); } usageData.modelLimits.forEach((item, index) => { const labels = durationLabels(item, "模型配额", "模型"); const { modelName, windowLabel } = splitLegacyModelName(item); const rowTitle = modelName; const meta = [windowLabel, compactDurationLabel(labels.label)] .filter(Boolean) .join(" · "); rows.push({ key: `model-${index}`, icon: "🧠", kind: "model", title: rowTitle, meta, label: [rowTitle, meta].filter(Boolean).join(" · "), short: /^Fable 5$/i.test(modelName) ? "Fable" : modelName.slice(0, 8), ...item, }); }); return rows; } function getPanelMetrics() { // 与共享设计语言一致:收起卡 96px、展开卡 304px(CSS 内已按视口收窄)。 return { defaultRight: 8, collapsedWidth: 96, expandedWidth: Math.min(304, window.innerWidth - 24), }; } function renderPanel() { if ( !document.body || !panel || !document.getElementById("claude-usage-panel-bottom") ) return; applyTheme(); if (provider === "claude") { renderClaudePanel(); } else { renderChatGPTPanel(); } startCountdown(); } function startCountdown() { if (countdownTimer) clearInterval(countdownTimer); // 两端都走增量渲染:只更新既有节点文本,不重建 DOM。 countdownTimer = setInterval(() => { if (provider === "claude") { renderClaudePanel(); } else { renderChatGPTPanel(); } }, 30000); } async function fetchUsage() { usageData.fetchError = null; renderPanel(); try { const snapshot = provider === "chatgpt" ? await fetchChatGPTUsage() : await fetchClaudeUsage(); if (!snapshot?.hit) throw new Error("接口未返回可识别的额度窗口"); usageData.fiveHour = snapshot.fiveHour; usageData.sevenDay = snapshot.sevenDay; usageData.modelLimits = snapshot.modelLimits; usageData.resetCredits = snapshot.resetCredits ?? null; usageData.planName = snapshot.planName; usageData.lastFetch = Date.now(); usageData.fetchError = null; } catch (error) { const prefix = provider === "chatgpt" ? "ChatGPT/Codex" : "Claude"; const message = error instanceof Error ? error.message : String(error); usageData.fetchError = `${prefix} 用量获取失败:${message}`; console.warn(`[${prefix}用量]`, error); } renderPanel(); } async function fetchClaudeUsage() { if (!orgId) { await discoverOrgId(); if (!orgId) throw new Error("未找到组织 ID,请刷新 Claude 页面"); } const endpoints = [ `https://claude.ai/api/organizations/${orgId}/usage`, `https://claude.ai/api/organizations/${orgId}/rate_limit_status`, `https://claude.ai/api/organizations/${orgId}/limits`, ]; let merged = null; let lastError = null; for (const url of endpoints) { try { const res = await _origFetch(url, { credentials: "include", headers: { Accept: "application/json" }, }); if (res.status === 404) continue; if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const parsed = UsageParsers.parseClaude(data); if (parsed.hit) merged = UsageParsers.merge(merged, parsed); // 新版 /usage 已携带 limits[],无需再重复请求旧的回退接口。 if (url.endsWith("/usage") && parsed.hasScopedSurface) break; } catch (error) { lastError = error; console.warn("[Claude用量] 接口失败:", url, error.message); } } if (merged?.hit) return merged; throw lastError ?? new Error("所有用量接口均不可用"); } function decodeJwtPayload(token) { try { const encoded = token.split(".")[1]; if (!encoded) return {}; const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd( normalized.length + ((4 - (normalized.length % 4)) % 4), "=", ); return JSON.parse(atob(padded)); } catch { return {}; } } function findChatGPTAccountId(session, accessToken) { const claims = decodeJwtPayload(accessToken); const authClaims = claims["https://api.openai.com/auth"] ?? {}; return ( session.account?.id ?? session.accountId ?? session.account_id ?? session.user?.accountId ?? session.user?.account_id ?? authClaims.chatgpt_account_id ?? claims.chatgpt_account_id ?? "" ); } async function fetchChatGPTSession() { const response = await _origFetch("https://chatgpt.com/api/auth/session", { credentials: "include", headers: { Accept: "application/json" }, }); if (!response.ok) throw new Error(`登录状态接口 HTTP ${response.status}`); const session = await response.json(); const accessToken = session.accessToken ?? session.access_token; if (!accessToken) throw new Error("请先登录 chatgpt.com"); return { accessToken, accountId: findChatGPTAccountId(session, accessToken), }; } async function fetchChatGPTUsage() { const { accessToken, accountId } = await fetchChatGPTSession(); const headers = { Accept: "application/json", Authorization: `Bearer ${accessToken}`, }; if (accountId) headers["ChatGPT-Account-Id"] = accountId; const endpoints = [ "https://chatgpt.com/backend-api/codex/usage", "https://chatgpt.com/backend-api/wham/usage", "https://chatgpt.com/api/codex/usage", ]; let lastError = null; for (const url of endpoints) { try { const response = await _origFetch(url, { credentials: "include", headers, }); if (response.status === 404) continue; if (response.status === 401 || response.status === 403) { throw new Error(`HTTP ${response.status},当前账号可能没有 Codex 权限`); } if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); const parsed = UsageParsers.parseChatGPT(data); if (parsed.hit) { parsed.resetCredits = await fetchChatGPTResetCredits( headers, parsed.resetCredits, ); return parsed; } lastError = new Error("接口响应中没有额度窗口"); } catch (error) { lastError = error; console.warn("[ChatGPT/Codex用量] 接口失败:", url, error.message); } } throw lastError ?? new Error("所有用量接口均不可用"); } async function fetchChatGPTResetCredits(headers, fallbackSummary) { const endpoints = [ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", "https://chatgpt.com/api/codex/rate-limit-reset-credits", ]; for (const url of endpoints) { try { const response = await _origFetch(url, { credentials: "include", headers, }); if (response.status === 404) continue; if (!response.ok) throw new Error(`HTTP ${response.status}`); const parsed = UsageParsers.parseChatGPTResetCredits( await response.json(), ); if (parsed) return parsed; } catch (error) { console.warn("[ChatGPT重置卡] 接口失败:", url, error.message); } } return fallbackSummary ?? null; } function enableDrag() { if (!panel) return; let startX, startY, startLeft, startTop, pointerMoved; panel.addEventListener("pointerdown", (e) => { if (e.button !== undefined && e.button !== 0) return; isDragging = true; pointerMoved = false; startX = e.clientX; startY = e.clientY; // 获取当前位置 const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; panel.style.transition = "none"; panel.style.cursor = "grabbing"; panel.setPointerCapture?.(e.pointerId); }); document.addEventListener("pointermove", (e) => { if (!isDragging) return; e.preventDefault(); const deltaX = e.clientX - startX; const deltaY = e.clientY - startY; if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) { pointerMoved = true; setChatGPTWidgetState(false); } let newLeft = startLeft + deltaX; let newTop = startTop + deltaY; // 边界限制 - 使用当前布局的收起宽度作为基准 const collapsedWidth = getPanelMetrics().collapsedWidth; const maxLeft = window.innerWidth - collapsedWidth; const maxTop = window.innerHeight - panel.offsetHeight; newLeft = Math.max(0, Math.min(newLeft, maxLeft)); newTop = Math.max(0, Math.min(newTop, maxTop)); panel.style.left = newLeft + "px"; panel.style.top = newTop + "px"; panel.style.right = "auto"; panel.style.bottom = "auto"; }); document.addEventListener("pointerup", (e) => { if (isDragging) { isDragging = false; panel.style.transition = "all 0.2s ease"; panel.style.cursor = "move"; panel.releasePointerCapture?.(e.pointerId); if (pointerMoved) { // 垂直位置自由,水平吸附到卡片中心更近的一侧(transition 提供吸边动画)。 const rect = panel.getBoundingClientRect(); savedPosition.top = rect.top; savedPosition.isRight = rect.left + rect.width / 2 > window.innerWidth / 2; localStorage.setItem( positionStorageKey, JSON.stringify({ top: savedPosition.top, isRight: savedPosition.isRight, }), ); applyChatGPTPosition(); } else if (e.pointerType !== "mouse") { // 触屏 tap 切换展开/收起 setChatGPTWidgetState(!isHovered); } renderPanel(); } }); document.addEventListener("pointercancel", (e) => { if (!isDragging) return; isDragging = false; panel.style.transition = "all 0.2s ease"; panel.style.cursor = "move"; panel.releasePointerCapture?.(e.pointerId); // 拖动被系统打断:回弹到上次保存的停靠位,避免浮窗悬在半空。 applyChatGPTPosition(); renderPanel(); }); } function init(options = {}) { if (document.getElementById("claude-usage-panel-bottom")) { console.warn(`[${panelTitle}] 小部件已存在`); return; } if (provider === "claude") hookFetch(); panel = createPanel(); // 支持自定义位置覆盖 if (options.position) { const position = options.position; if (position.bottom) panel.style.bottom = position.bottom; if (position.left) panel.style.left = position.left; if (position.top) panel.style.top = position.top; if (position.right) panel.style.right = position.right; } const initWhenReady = () => { if (!document.body) { setTimeout(initWhenReady, 100); return; } document.body.appendChild(panel); // 恢复保存的停靠位置:只取垂直位置与停靠边,水平永远吸边。 // 旧版本存过的任意悬空坐标(left/right 偏移)在这里自动归位贴边。 const savedPos = localStorage.getItem(positionStorageKey); if (provider === "chatgpt" && savedPos && !options.position) { try { const pos = JSON.parse(savedPos); const top = parseFloat(pos.top); if (Number.isFinite(top)) savedPosition.top = top; savedPosition.isRight = pos.isRight !== false; applyChatGPTPosition(); } catch (e) { console.warn(`[${panelTitle}] 恢复位置失败`, e); } } renderPanel(); if (provider === "chatgpt") { enableDrag(); panel.addEventListener("mouseenter", () => { if (!isDragging) setChatGPTWidgetState(true); }); panel.addEventListener("mouseleave", () => { if (!isDragging) setChatGPTWidgetState(false); }); } else { applyClaudePosition(); } if (provider === "claude") { discoverOrgId().then(() => fetchUsage()); } else { fetchUsage(); } refreshInterval = setInterval(() => { if (provider === "chatgpt" || orgId) fetchUsage(); }, 65000); const themeObserver = new MutationObserver(applyTheme); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"], }); window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", applyTheme); console.log( `%c✅ ${panelTitle}小部件已启动`, "color:#10b981;font-weight:600;font-size:13px", ); }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initWhenReady); } else { initWhenReady(); } } function destroy() { if (panel && panel.parentNode) { panel.parentNode.removeChild(panel); } if (countdownTimer) clearInterval(countdownTimer); if (autoRefreshTimer) clearTimeout(autoRefreshTimer); if (claudeAutoCollapseTimer) clearTimeout(claudeAutoCollapseTimer); if (refreshInterval) clearInterval(refreshInterval); if (claudeDocumentClickHandler) { document.removeEventListener("click", claudeDocumentClickHandler); } if (claudeKeyHandler) { document.removeEventListener("keydown", claudeKeyHandler); } panel = null; claudeShadow = null; orgId = null; console.log(`[${panelTitle}] 小部件已销毁`); } return { init, destroy, getUsageData: () => usageData, }; })(); ClaudeUsageWidget.init(); if (isClaudeSite) { // 动态首页文案通过 DOM 处理;Design 页面继续兼容打包在 JS bundle 中的静态字符串。 function isDesignPage() { return location.pathname.startsWith("/design"); } function shouldSkipTranslation(node) { let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; while (element) { const tagName = String(element.tagName || "").toUpperCase(); if ( ["SCRIPT", "STYLE", "TEXTAREA", "INPUT"].includes(tagName) || element.isContentEditable || element.getAttribute?.("contenteditable") === "true" ) { return true; } element = element.parentElement; } return false; } function translateAttrs(el) { if (!isDesignPage()) return; for (const attr of ["title", "placeholder", "aria-label"]) { const val = el.getAttribute(attr); if (val && DESIGN_TRANSLATIONS[val]) { el.setAttribute(attr, DESIGN_TRANSLATIONS[val]); } } } function translateTextNode(node) { if (shouldSkipTranslation(node)) return; const raw = node.nodeValue; const text = raw && raw.trim(); if (!text) return; const dynamicTranslation = DynamicTranslations.translate(text); if (dynamicTranslation !== text) { node.nodeValue = raw.replace(text, dynamicTranslation); return; } if (isDesignPage() && DESIGN_TRANSLATIONS[text]) { node.nodeValue = raw.replace(text, DESIGN_TRANSLATIONS[text]); } } function translateDynamicContainers(root) { if (!root) return; const seedNodes = []; if (root.nodeType === Node.TEXT_NODE) { seedNodes.push(root); } else if (root.nodeType === Node.ELEMENT_NODE) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let textNode; while ((textNode = walker.nextNode())) seedNodes.push(textNode); } const candidates = new Set(); for (const textNode of seedNodes) { if (!/(?:you|used|fable|resets)/i.test(textNode.nodeValue || "")) { continue; } let element = textNode.parentElement; for (let depth = 0; element && depth < 5; depth += 1) { candidates.add(element); if (element === root) break; element = element.parentElement; } } const orderedCandidates = [...candidates].sort((a, b) => { const depth = (element) => { let value = 0; while (element?.parentElement) { value += 1; element = element.parentElement; } return value; }; return depth(b) - depth(a); }); for (const element of orderedCandidates) { if (shouldSkipTranslation(element)) continue; const combined = String(element.textContent || "").trim(); if (!combined || combined.length > 240) continue; const translated = DynamicTranslations.translate(combined); if (translated === combined) continue; const textNodes = []; const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, ); let textNode; while ((textNode = walker.nextNode())) { if (textNode.nodeValue?.trim() && !shouldSkipTranslation(textNode)) { textNodes.push(textNode); } } if (!textNodes.length) continue; const first = textNodes[0]; const raw = first.nodeValue || ""; const leading = raw.match(/^\s*/)?.[0] || ""; const trailing = raw.match(/\s*$/)?.[0] || ""; first.nodeValue = `${leading}${translated}${trailing}`; for (const extraNode of textNodes.slice(1)) extraNode.nodeValue = ""; } } function translateNode(node) { if (node.nodeType === Node.TEXT_NODE) { translateTextNode(node); } else if ( node.nodeType === Node.ELEMENT_NODE && !shouldSkipTranslation(node) ) { translateDynamicContainers(node); translateAttrs(node); const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT); let textNode; while ((textNode = walker.nextNode())) { translateTextNode(textNode); } if (isDesignPage()) { node .querySelectorAll("[title],[placeholder],[aria-label]") .forEach(translateAttrs); } } } const claudeDomObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === "characterData") { translateTextNode(mutation.target); translateDynamicContainers(mutation.target); } else if ( mutation.type === "attributes" && mutation.target.nodeType === Node.ELEMENT_NODE ) { translateAttrs(mutation.target); } else { for (const node of mutation.addedNodes) { translateNode(node); } } } }); function initClaudeDomTranslator() { translateNode(document.body); claudeDomObserver.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["title", "placeholder", "aria-label"], }); } if (document.body) { initClaudeDomTranslator(); } else { document.addEventListener("DOMContentLoaded", initClaudeDomTranslator); } } })();