// ==UserScript== // @name OpenCode Go Usage Export // @namespace https://github.com/Shirolin/opencode-go-usage-export // @version 1.0.4 // @author Shirolin // @run-at document-start // @description OpenCode Go usage dashboard & export — in-page stats panel (totals, cost, Go quota 5h/7d/30d, breakdowns by model / API key / plan), network-layer capture, 30-day detail + permanent aggregates in IndexedDB, incremental sync, CSV + Excel export. ⚠ Install only from the official GitHub repository (github.com/Shirolin/opencode-go-usage-export); modified copies from unknown sources may steal your API keys. // @match https://opencode.ai/workspace/*/usage // @match https://opencode.ai/*/workspace/*/usage // @require https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js // @license GPL-3.0-or-later // @homepageURL https://github.com/Shirolin/opencode-go-usage-export // @supportURL https://github.com/Shirolin/opencode-go-usage-export/issues // @downloadURL https://raw.githubusercontent.com/Shirolin/opencode-go-usage-export/main/opencode-go-usage-export.user.js // @updateURL https://raw.githubusercontent.com/Shirolin/opencode-go-usage-export/main/opencode-go-usage-export.user.js // @grant none // ==/UserScript== ;(function () { "use strict" const IDB_NAME = "oc-go-usage-export-v5" const IDB_STORE = "workspaces" const PAGE_SIZE = 50 const CONC = 1 // 顺序拉页,避免短时间大量 /_server 请求 const MAX_PAGES = 2000 // 全量抓取页数硬上限(2000×50≈10 万行),防失控长跑 const AUTO_GAP_MS = 6 * 3600e3 const STALE_MS = 30 * 24 * 3600e3 const WINDOW_MS = 30 * 24 * 3600e3 // 明细保留窗口 const SET_KEY = "oc-go-usage-auto" const SETTINGS_KEY = "oc-go-export-settings-v1" const PANEL_OPEN_KEY = "oc-go-export-open" const SEC_WARN_KEY = "oc-go-export-warned-v1" const SEC_WARN_URL = "https://github.com/Shirolin/opencode-go-usage-export" const DEFAULT_SETTINGS = { displayMode: "compact", autoSync: true, clickOutsideClose: true, exportPresetDays: 30, statPresetDays: 30, exportSectionOpen: true, overviewOpen: true, dimensionsOpen: true, pageGapMs: 350, topKeyCount: 5, topModelCount: 6, dateFormat: "auto", lang: "", } const LOCALES = { zh: { // panel / head panelTitle: "OpenCode Go 用量导出", toggleOpen: "展开 OpenCode Go 用量导出", toggleClose: "收起面板", btnExpandCompact: "切换紧凑模式", btnExpandLarge: "切换大窗口", btnSettings: "设置", btnClose: "收起", // action buttons btnFull: "全量抓取", btnInc: "增量抓取", btnRefresh: "刷新面板", btnNames: "更新 key 名称", btnClear: "清空缓存", btnClearConfirm: "确认清空?(点击确认)", // settings settingsTitle: "设置", settingGroupDisplay: "显示", settingGroupSync: "同步", settingGroupExport: "导出", settingGroupPanel: "面板", settingGroupAdvanced: "高级选项", settingDisplayMode: "显示模式", settingDisplayCompact: "紧凑", settingDisplayLarge: "大窗口", settingClickOutside: "点击外部关闭", settingAutoSync: "自动同步", settingAutoSyncNote: "(>6h 增量)", settingExportPreset: "默认日期区间", settingExportOpen: "导出区默认展开", settingOverviewOpen: "概览默认展开", settingDimensionsOpen: "维度分析默认展开", settingPageGap: "拉页间隔", settingTopModel: "模型排行数", settingTopKey: "Key 排行数", settingLang: "语言", settingLangAuto: "自动", settingDateFormat: "日期格式", settingDateFormatAuto: "自动(跟随浏览器语言)", // export block exportTitle: "导出数据", exportPreset7: "近7天", exportPreset30: "近30天", exportPresetAll: "全部", exportCsv: "导出 CSV", exportExcel: "导出 Excel", // stats / overview statOverview: "概览", statWindowLabel: "统计窗口", statRangeLabel: "数据范围", statWindow7d: "近 7 天明细", statWindow30d: "近 30 天明细", statWindowAll: "全部数据", statTotalRequests: "共 {0} 次请求", statCost30d: "成本(近30天)", statQuotaTitle: "Go 限额(近 30 天明细)", statQuota5h: "5 小时", statQuota7d: "7 天", statQuota30d: "30 天", statFootDetail: "明细 {0} 条 + 汇总 {1} 组", statFootKeys: "已命名 key {0} 个", statEmptyDim: "暂无维度数据,请先抓取", statEmptyDetail: "暂无明细", statSummaryGroups: "汇总 {0} 组", dimByModel: "按模型 · {0}", dimByKey: "按 API key · {0}", dimByPlan: "按 plan · {0}", unitTok: " tok", unitReqs: " 次", unitReqsPlain: "次", // key labels keyUnknown: "未标识(dom)", dateUnknown: "未知", // status / info messages msgReady: "就绪", msgRefreshed: "已刷新", msgCleared: "已清空", msgAutoSync: "自动增量同步中…", msgFetchProgressInc: "增量抓取中… 已拉 {0} 条", msgFetchProgressFull: "全量抓取中… 已拉 {0} 条", msgDomFallback: "网络捕获失败,改用页面抓取(较慢)…", msgDomProgress: "页面抓取中… {0} 条", msgDoneInc: "增量完成:{0} 源 · 新增 {1} 条 · 明细 {2} / 汇总 {3}", msgDoneFull: "全量完成:{0} 源 · 新增 {1} 条 · 明细 {2} / 汇总 {3}", msgDedup: " · 去重 {0} 条", msgInfoBar: "明细 {0} / 汇总 {1} 组 · 上次同步:{2}", msgLastSync: "上次同步: {0}", msgNoKeys: "暂无带 keyID 的数据,请先抓取 network 明细", msgKeyRefreshing: "更新 API key 名称中…", msgKeyRefreshed: "已更新 {0} 个 API key 名称", msgExportEmpty: "所选区间无数据", msgNoSheetjs: "SheetJS 未加载,请用 CSV", msgExportDone: "已导出:明细 {0} 条 · 汇总 {1} 组 · {2} ~ {3}", msgError: "出错: {0}", msgTimeout: "操作超时(10 分钟)", msgNone: "无", securityWarnTitle: "安全提示", securityWarnBody: "本脚本会直接访问 OpenCode 后台接口(含你的登录会话与 API Key 相关数据)。请仅从官方仓库安装:github.com/Shirolin/opencode-go-usage-export(公开开源,可审计代码)。来路不明的修改版可能窃取你的 API Key、用量数据甚至账号会话——安装前请核对来源。", securityWarnOk: "我知道了,不再提示", securityWarnRepo: "查看官方仓库", securityNote: "安全:仅使用官方版本 github.com/Shirolin/opencode-go-usage-export;本脚本可访问后台数据,未知来源修改版有泄露 API Key 风险", btnCancel: "停止", msgCancelled: "已取消,已保存 {0} 条", msgPageCap: " · 已达页数上限 {0} 页,可能未抓全,建议增量同步", }, en: { panelTitle: "OpenCode Go Usage", toggleOpen: "Open OpenCode Go Usage", toggleClose: "Hide panel", btnExpandCompact: "Compact view", btnExpandLarge: "Expanded view", btnSettings: "Settings", btnClose: "Close", btnFull: "Full sync", btnInc: "Sync new", btnRefresh: "Refresh", btnNames: "Refresh key names", btnClear: "Clear data", btnClearConfirm: "Confirm clear? (click again)", settingsTitle: "Settings", settingGroupDisplay: "Appearance", settingGroupSync: "Sync", settingGroupExport: "Export", settingGroupPanel: "Panel", settingGroupAdvanced: "Advanced", settingDisplayMode: "Layout", settingDisplayCompact: "Compact", settingDisplayLarge: "Wide", settingClickOutside: "Click outside to dismiss", settingAutoSync: "Auto-sync", settingAutoSyncNote: "(incremental, >6h)", settingExportPreset: "Default date range", settingExportOpen: "Show export section by default", settingOverviewOpen: "Show overview by default", settingDimensionsOpen: "Show breakdowns by default", settingPageGap: "Request delay", settingTopModel: "Models to show", settingTopKey: "Keys to show", settingLang: "Language", settingLangAuto: "Auto", settingDateFormat: "Date format", settingDateFormatAuto: "Auto (browser language)", exportTitle: "Export", exportPreset7: "Last 7 days", exportPreset30: "Last 30 days", exportPresetAll: "All time", exportCsv: "Export CSV", exportExcel: "Export Excel", statOverview: "Overview", statWindowLabel: "Window", statRangeLabel: "Date range", statWindow7d: "Last 7 days", statWindow30d: "30-day rolling window", statWindowAll: "All time", statTotalRequests: "{0} requests", statCost30d: "Cost (30d)", statQuotaTitle: "Go quota (last 30d)", statQuota5h: "5-hour", statQuota7d: "7-day", statQuota30d: "30-day", statFootDetail: "{0} records · {1} aggregates", statFootKeys: "{0} named keys", statEmptyDim: "No data yet — run a sync first", statEmptyDetail: "No records", statSummaryGroups: "{0} aggregates", dimByModel: "Model · {0}", dimByKey: "API key · {0}", dimByPlan: "Plan · {0}", unitTok: " tok", unitReqs: " req", unitReqsPlain: "req", keyUnknown: "unknown (dom)", dateUnknown: "unknown", msgReady: "Ready", msgRefreshed: "Refreshed", msgCleared: "Data cleared", msgAutoSync: "Auto-syncing…", msgFetchProgressInc: "Syncing… {0} rows fetched", msgFetchProgressFull: "Full sync… {0} rows fetched", msgDomFallback: "Network intercept failed — falling back to DOM scrape (slower)…", msgDomProgress: "Scraping page… {0} rows", msgDoneInc: "Sync done: {0} · +{1} new · {2} records / {3} aggregates", msgDoneFull: "Full sync done: {0} · +{1} new · {2} records / {3} aggregates", msgDedup: " · {0} dupes skipped", msgInfoBar: "{0} records · {1} aggregates · Last sync: {2}", msgLastSync: "Last sync: {0}", msgNoKeys: "No keyID data — run a network sync first", msgKeyRefreshing: "Fetching API key names…", msgKeyRefreshed: "Updated {0} key name(s)", msgExportEmpty: "No data in the selected range", msgNoSheetjs: "SheetJS unavailable — use CSV instead", msgExportDone: "Exported: {0} records · {1} aggregates · {2} – {3}", msgError: "Error: {0}", msgTimeout: "Operation timed out (10 min)", msgNone: "—", securityWarnTitle: "Security notice", securityWarnBody: "This script directly accesses OpenCode backend APIs, including your signed-in session and API-key related data. Install only from the official repository: github.com/Shirolin/opencode-go-usage-export (public, open source, auditable). Modified copies from unknown sources may steal your API keys, usage data, or account session — verify the source before installing.", securityWarnOk: "Got it — don't show again", securityWarnRepo: "Open official repo", securityNote: "Security: use only the official version github.com/Shirolin/opencode-go-usage-export; this script can access backend data — modified copies from unknown sources risk leaking your API keys", btnCancel: "Stop", msgCancelled: "Cancelled — {0} rows saved", msgPageCap: " · reached page cap ({0}), data may be incomplete — try incremental sync", }, ja: { panelTitle: "OpenCode Go 利用状況", toggleOpen: "OpenCode Go 利用状況を開く", toggleClose: "閉じる", btnExpandCompact: "コンパクト表示", btnExpandLarge: "広い表示", btnSettings: "設定", btnClose: "閉じる", btnFull: "全件取得", btnInc: "差分取得", btnRefresh: "再読み込み", btnNames: "Key名を再取得", btnClear: "データ削除", btnClearConfirm: "本当に削除?(もう一度クリック)", settingsTitle: "設定", settingGroupDisplay: "表示", settingGroupSync: "同期", settingGroupExport: "エクスポート", settingGroupPanel: "パネル", settingGroupAdvanced: "詳細設定", settingDisplayMode: "レイアウト", settingDisplayCompact: "コンパクト", settingDisplayLarge: "ワイド", settingClickOutside: "外側クリックで閉じる", settingAutoSync: "自動同期", settingAutoSyncNote: "(差分・6h 経過後)", settingExportPreset: "デフォルト期間", settingExportOpen: "エクスポート欄を初期展開", settingOverviewOpen: "概要を初期展開", settingDimensionsOpen: "内訳を初期展開", settingPageGap: "リクエスト間隔", settingTopModel: "表示モデル数", settingTopKey: "表示Key数", settingLang: "言語", settingLangAuto: "自動", settingDateFormat: "日付形式", settingDateFormatAuto: "自動(ブラウザ言語に従う)", exportTitle: "エクスポート", exportPreset7: "直近7日", exportPreset30: "直近30日", exportPresetAll: "全期間", exportCsv: "CSVで保存", exportExcel: "Excelで保存", statOverview: "概要", statWindowLabel: "集計期間", statRangeLabel: "データ範囲", statWindow7d: "直近7日分", statWindow30d: "直近30日分", statWindowAll: "全期間", statTotalRequests: "{0} リクエスト", statCost30d: "費用(30日)", statQuotaTitle: "Go 利用上限(直近30日)", statQuota5h: "5時間", statQuota7d: "7日間", statQuota30d: "30日間", statFootDetail: "{0} 件 · 集計 {1} グループ", statFootKeys: "Key名登録済み {0} 件", statEmptyDim: "データがありません。まず取得してください", statEmptyDetail: "データなし", statSummaryGroups: "集計 {0} グループ", dimByModel: "モデル別 · {0}", dimByKey: "APIキー別 · {0}", dimByPlan: "プラン別 · {0}", unitTok: " tok", unitReqs: " 回", unitReqsPlain: "回", keyUnknown: "不明 (dom)", dateUnknown: "不明", msgReady: "準備完了", msgRefreshed: "更新しました", msgCleared: "データを削除しました", msgAutoSync: "バックグラウンド同期中…", msgFetchProgressInc: "差分取得中… {0} 件", msgFetchProgressFull: "全件取得中… {0} 件", msgDomFallback: "ネットワーク取得に失敗。ページ解析に切り替えます(低速)…", msgDomProgress: "ページ解析中… {0} 件", msgDoneInc: "差分完了 ({0}): +{1} 件追加 · {2} 件 / {3} グループ", msgDoneFull: "全件完了 ({0}): +{1} 件追加 · {2} 件 / {3} グループ", msgDedup: " · 重複 {0} 件をスキップ", msgInfoBar: "{0} 件 · {1} グループ · 最終同期: {2}", msgLastSync: "最終同期: {0}", msgNoKeys: "keyIDが含まれるデータがありません。先にネットワーク取得してください", msgKeyRefreshing: "APIキー名を取得中…", msgKeyRefreshed: "{0} 件のキー名を更新しました", msgExportEmpty: "指定した期間にデータがありません", msgNoSheetjs: "SheetJS が読み込まれていません。CSV をお使いください", msgExportDone: "保存しました: {0} 件 · {1} グループ · {2} ~ {3}", msgError: "エラー: {0}", msgTimeout: "操作がタイムアウトしました(10分)", msgNone: "—", securityWarnTitle: "セキュリティ注意", securityWarnBody: "このスクリプトは OpenCode のバックエンド API に直接アクセスします(ログインセッションや API キー関連データを含む)。公式リポジトリからのみインストールしてください:github.com/Shirolin/opencode-go-usage-export(公開・オープンソース・監査可能)。出所不明の改変版は API キーや利用データ、アカウントセッションを窃取する恐れがあります。インストール前に入手元を確認してください。", securityWarnOk: "了解しました(今後表示しない)", securityWarnRepo: "公式リポジトリを開く", securityNote: "セキュリティ:公式バージョン github.com/Shirolin/opencode-go-usage-export のみを使用してください。本スクリプトはバックエンドデータにアクセスするため、出所不明の改変版は API キー漏洩のリスクがあります", btnCancel: "停止", msgCancelled: "キャンセルしました({0} 件保存)", msgPageCap: " · ページ上限 {0} に達しました(一部欠落の可能性)・差分取得を推奨", }, "zh-tw": { panelTitle: "OpenCode Go 用量匯出", toggleOpen: "展開 OpenCode Go 用量面板", toggleClose: "收合面板", btnExpandCompact: "切換為緊湊模式", btnExpandLarge: "切換為寬螢幕模式", btnSettings: "設定", btnClose: "收合", btnFull: "全量同步", btnInc: "增量同步", btnRefresh: "重新整理", btnNames: "更新 Key 名稱", btnClear: "清除資料", btnClearConfirm: "確認清除?(再次點擊)", settingsTitle: "設定", settingGroupDisplay: "外觀", settingGroupSync: "同步", settingGroupExport: "匯出", settingGroupPanel: "面板", settingGroupAdvanced: "進階", settingDisplayMode: "版面配置", settingDisplayCompact: "緊湊", settingDisplayLarge: "寬螢幕", settingClickOutside: "點擊外側自動關閉", settingAutoSync: "自動同步", settingAutoSyncNote: "(增量,間隔 >6h)", settingExportPreset: "預設日期範圍", settingExportOpen: "預設展開匯出區塊", settingOverviewOpen: "預設展開概覽", settingDimensionsOpen: "預設展開維度分析", settingPageGap: "請求間隔", settingTopModel: "顯示模型數", settingTopKey: "顯示 Key 數", settingLang: "語言", settingLangAuto: "自動", settingDateFormat: "日期格式", settingDateFormatAuto: "自動(跟隨瀏覽器語言)", exportTitle: "匯出", exportPreset7: "近 7 天", exportPreset30: "近 30 天", exportPresetAll: "全部", exportCsv: "匯出 CSV", exportExcel: "匯出 Excel", statOverview: "概覽", statWindowLabel: "統計區間", statRangeLabel: "資料範圍", statWindow7d: "近 7 天明細", statWindow30d: "近 30 天明細", statWindowAll: "全部資料", statTotalRequests: "共 {0} 次請求", statCost30d: "費用(近 30 天)", statQuotaTitle: "Go 配額(近 30 天)", statQuota5h: "5 小時", statQuota7d: "7 天", statQuota30d: "30 天", statFootDetail: "明細 {0} 筆・彙整 {1} 組", statFootKeys: "已命名 Key {0} 個", statEmptyDim: "尚無資料,請先執行同步", statEmptyDetail: "尚無明細", statSummaryGroups: "彙整 {0} 組", dimByModel: "依模型 · {0}", dimByKey: "依 API Key · {0}", dimByPlan: "依方案 · {0}", unitTok: " tok", unitReqs: " 次", unitReqsPlain: "次", keyUnknown: "無法識別 (dom)", dateUnknown: "未知", msgReady: "就緒", msgRefreshed: "已重新整理", msgCleared: "資料已清除", msgAutoSync: "背景同步中…", msgFetchProgressInc: "增量同步中… 已取得 {0} 筆", msgFetchProgressFull: "全量同步中… 已取得 {0} 筆", msgDomFallback: "網路攔截失敗,改以頁面解析方式取得(較慢)…", msgDomProgress: "解析頁面中… {0} 筆", msgDoneInc: "增量同步完成({0}):新增 {1} 筆・明細 {2} 筆 / 彙整 {3} 組", msgDoneFull: "全量同步完成({0}):新增 {1} 筆・明細 {2} 筆 / 彙整 {3} 組", msgDedup: "・略過重複 {0} 筆", msgInfoBar: "明細 {0} 筆・彙整 {1} 組・上次同步:{2}", msgLastSync: "上次同步:{0}", msgNoKeys: "尚無含 keyID 的資料,請先執行網路同步", msgKeyRefreshing: "正在取得 API Key 名稱…", msgKeyRefreshed: "已更新 {0} 個 API Key 名稱", msgExportEmpty: "所選區間內無資料", msgNoSheetjs: "SheetJS 未載入,請改用 CSV", msgExportDone: "匯出完成:明細 {0} 筆・彙整 {1} 組・{2} ~ {3}", msgError: "錯誤:{0}", msgTimeout: "操作逾時(10 分鐘)", msgNone: "—", securityWarnTitle: "安全提示", securityWarnBody: "本腳本會直接存取 OpenCode 後端 API(含你的登入工作階段與 API Key 相關資料)。請僅從官方儲存庫安裝:github.com/Shirolin/opencode-go-usage-export(公開・開源・可審計)。來路不明的修改版可能竊取你的 API Key、用量資料甚至帳號工作階段——安裝前請核對來源。", securityWarnOk: "我知道了,不再提示", securityWarnRepo: "查看官方儲存庫", securityNote: "安全:僅使用官方版本 github.com/Shirolin/opencode-go-usage-export;本腳本可存取後端資料,未知來源修改版有洩漏 API Key 風險", btnCancel: "停止", msgCancelled: "已取消,已儲存 {0} 筆", msgPageCap: " · 已達頁數上限 {0} 頁,可能未抓全,建議增量同步", }, } function detectLang() { const saved = loadSettings().lang if (saved && LOCALES[saved]) return saved const nav = (navigator.language || "").toLowerCase() if (nav.startsWith("zh-tw") || nav.startsWith("zh-hk") || nav.startsWith("zh-hant")) return "zh-tw" if (nav.startsWith("zh")) return "zh" if (nav.startsWith("ja")) return "ja" return "en" } function t(key, ...args) { const dict = LOCALES[detectLang()] || LOCALES.zh let s = dict[key] ?? LOCALES.zh[key] ?? key args.forEach((v, i) => { s = s.replaceAll(`{${i}}`, String(v)) }) return s } const API_KEYS_SERVER_ID = "c22cd964237ba79f2f9b95faa2a14b804f870d1bab49279463379cc6a0fd0c85" const API_KEYS_SERVER_INSTANCE = "server-fn:4" const AGG_FIELDS = ["requests", "inputTokens", "cacheReadTokens", "cacheWriteTokens", "outputTokens", "reasoningTokens", "costUSD"] const AGG_COLS = ["key", ...AGG_FIELDS] const WS_ID = (() => { const m = location.pathname.match(/workspace\/([^/]+)/) return m ? m[1] : "unknown" })() const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const $q = (sel, el = document) => el.querySelector(sel) const $qa = (sel, el = document) => Array.from(el.querySelectorAll(sel)) const NUM = (s) => { const m = String(s ?? "").replace(/[^\d.]/g, "") return m ? parseFloat(m) : 0 } let settingsCache = null function loadSettings() { if (settingsCache) return settingsCache const s = { ...DEFAULT_SETTINGS } try { const raw = localStorage.getItem(SETTINGS_KEY) if (raw) Object.assign(s, JSON.parse(raw)) } catch {} if (!localStorage.getItem(SETTINGS_KEY)) { const oldAuto = localStorage.getItem(SET_KEY) if (oldAuto !== null) s.autoSync = oldAuto !== "0" } settingsCache = s return s } function saveSettings(partial) { settingsCache = { ...loadSettings(), ...partial } localStorage.setItem(SETTINGS_KEY, JSON.stringify(settingsCache)) localStorage.setItem(SET_KEY, settingsCache.autoSync ? "1" : "0") return settingsCache } function getPageGapMs() { const ms = loadSettings().pageGapMs return ms === 250 || ms === 350 || ms === 500 ? ms : DEFAULT_SETTINGS.pageGapMs } // 日期显示格式(可自定义):auto 跟随浏览器语言,其余为国际通用格式 function fmtDate(ms) { if (!ms) return "-" const f = loadSettings().dateFormat if (!f || f === "auto") return new Date(ms).toLocaleDateString() const d = new Date(ms) const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, "0") const day = String(d.getDate()).padStart(2, "0") if (f === "iso") return `${y}-${m}-${day}` if (f === "ymd") return `${y}/${m}/${day}` if (f === "dmy") return `${day}/${m}/${y}` if (f === "mdy") return `${m}/${day}/${y}` return new Date(ms).toLocaleDateString() } function fmtDateTime(ms) { return ms ? `${fmtDate(ms)} ${new Date(ms).toLocaleTimeString()}` : t("msgNone") } const autoEnabled = () => loadSettings().autoSync const setAuto = (v) => saveSettings({ autoSync: v }) const panelOpen = () => localStorage.getItem(PANEL_OPEN_KEY) === "1" const setPanelOpen = (v) => localStorage.setItem(PANEL_OPEN_KEY, v ? "1" : "0") function applyDisplayMode(mode) { const root = document.getElementById("oc-go-export-root") const backdrop = document.getElementById("oc-go-export-backdrop") const btnExpand = document.getElementById("oc-go-export-expand") if (!root) return const m = mode || loadSettings().displayMode root.classList.toggle("oc-mode-large", m === "large") root.classList.toggle("oc-mode-compact", m !== "large") if (backdrop) backdrop.hidden = m !== "large" || !root.classList.contains("oc-open") if (btnExpand) { btnExpand.textContent = m === "large" ? "⤡" : "⤢" btnExpand.title = m === "large" ? t("btnExpandCompact") : t("btnExpandLarge") } syncSettingsUI() } function syncSettingsUI() { const s = loadSettings() const setVal = (sel, val) => { const el = $q(sel) if (!el) return if (el.type === "checkbox") el.checked = !!val else el.value = String(val) } const setRadio = (name, val) => { const el = $q(`input[name="${name}"][value="${val}"]`) if (el) el.checked = true } setRadio("oc-display-mode", s.displayMode) setRadio("oc-lang", s.lang || "") setVal("#oc-set-click-outside", s.clickOutsideClose) setVal("#oc-set-auto-sync", s.autoSync) setRadio("oc-export-preset", String(s.exportPresetDays)) setVal("#oc-set-export-open", s.exportSectionOpen) setVal("#oc-set-overview-open", s.overviewOpen) setVal("#oc-set-dimensions-open", s.dimensionsOpen) setVal("#oc-set-page-gap", s.pageGapMs) setVal("#oc-set-top-model", s.topModelCount) setVal("#oc-set-top-key", s.topKeyCount) setVal("#oc-set-date-format", s.dateFormat) const meta = $q("#oc-set-meta") if (meta) { getWorkspaceData() .then((ws) => { const last = ws.lastSync ? fmtDateTime(ws.lastSync) : t("msgNone") meta.textContent = `Workspace: ${WS_ID} · ${t("msgLastSync", last)}` }) .catch(() => { meta.textContent = `Workspace: ${WS_ID}` }) } } function waitFor(fn, timeout = 3000) { return new Promise((resolve) => { const start = Date.now() const timer = setInterval(() => { if (fn() || Date.now() - start > timeout) { clearInterval(timer) resolve() } }, 80) }) } // ---------- IndexedDB ---------- function idbOpen() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, 1) req.onupgradeneeded = () => { const db = req.result if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE, { keyPath: "id" }) } req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error) }) } function idbRequest(mode, fn) { return idbOpen().then( (db) => new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, mode) const store = tx.objectStore(IDB_STORE) const req = fn(store) req.onerror = () => reject(req.error) tx.onerror = () => reject(tx.error) if (mode === "readwrite") tx.oncomplete = () => resolve(req.result) else req.onsuccess = () => resolve(req.result) }), ) } const idbGet = (id) => idbRequest("readonly", (s) => s.get(id)) const idbPut = (rec) => idbRequest("readwrite", (s) => s.put(rec)) const idbGetAll = () => idbRequest("readonly", (s) => s.getAll()) const idbDelete = (id) => idbRequest("readwrite", (s) => s.delete(id)) const emptyRec = () => ({ id: WS_ID, detail: [], summary: [], keyNames: {}, lastSync: null, lastAccess: null, lastKeySync: null }) async function getWorkspaceData() { const rec = await idbGet(WS_ID) if (!rec) return emptyRec() if (Array.isArray(rec.rows)) { // 迁移 v4 缓存 → 分层存储 const cutoff = Date.now() - WINDOW_MS const { detail, summary } = rollup(rec.rows, [], cutoff) const migrated = { id: WS_ID, detail, summary, lastSync: rec.lastSync, lastAccess: rec.lastAccess } await saveWorkspaceData(migrated) return migrated } return { ...emptyRec(), ...rec, keyNames: rec.keyNames || {} } } const saveWorkspaceData = (rec) => idbPut(rec) async function pruneStale() { try { const now = Date.now() for (const rec of await idbGetAll()) { const last = rec.lastAccess || rec.lastSync if (last && now - last > STALE_MS) await idbDelete(rec.id) } } catch {} } async function touch() { try { const rec = await getWorkspaceData() rec.lastAccess = Date.now() await saveWorkspaceData(rec) } catch {} } // ---------- 网络层拦截(SolidStart /_server + 旧版路径) ---------- let observed = null function headerObj(h) { const headers = {} if (h instanceof Headers) h.forEach((v, k) => (headers[k] = v)) else if (h && typeof h === "object") Object.assign(headers, h) return headers } function serovalWireToArray(parsed) { const node = parsed?.t if (!node || node.t !== 9 || !Array.isArray(node.a)) return null return node.a.map((item) => (item && typeof item === "object" && "s" in item ? item.s : item)) } function decodeArgs(raw) { if (raw == null || raw === "") return null let text = String(raw) try { text = decodeURIComponent(text) } catch {} try { const parsed = JSON.parse(text) if (Array.isArray(parsed)) return parsed const fromWire = serovalWireToArray(parsed) if (fromWire) return fromWire } catch {} const m = text.match(/\$R\[\d+\]=(.+)$/) if (m) { try { return Function(`"use strict"; return (${m[1]})`)() } catch {} } return null } function usageArgs(args) { // usage.list(workspaceID, page) — 仅 2 个参数;4 参数是图表 getCosts,应忽略 if (!Array.isArray(args) || args.length !== 2) return null const [ws, page] = args if (typeof ws !== "string" || !/^wrk_/.test(ws) || ws !== WS_ID) return null if (typeof page !== "number" || !Number.isInteger(page) || page < 0) return null return { ws, page } } function maybeCapture(method, url, headers, body) { if (!url || observed) return const u = String(url) const isServer = /\/_server(?:\?|$)/.test(u) const isLegacy = /workspace\/.+\/usage/.test(u) if (!isServer && !isLegacy) return let args = null if (isServer) { if (method === "GET") { try { const q = new URL(u, location.origin) args = decodeArgs(q.searchParams.get("args")) } catch {} } else if (body != null) { args = decodeArgs(body) } if (!usageArgs(args)) return observed = { kind: "server", method, url: u, headers, body: body == null ? null : String(body), args } return } if (method === "POST") { observed = { kind: "legacy", method, url: u, headers, body: body == null ? null : String(body), args: null } } } let origFetch = window.fetch.bind(window) window.fetch = async function (input, init) { const url = typeof input === "string" ? input : input?.url const method = (init?.method || input?.method || "GET").toUpperCase() let body = null try { if (init?.body != null) body = String(init.body) else if (input instanceof Request && method !== "GET" && method !== "HEAD") body = await input.clone().text() } catch {} maybeCapture(method, url, headerObj(init?.headers || input?.headers), body) return origFetch(input, init) } const origXHROpen = XMLHttpRequest.prototype.open const origXHRSend = XMLHttpRequest.prototype.send const origXHRSetHeader = XMLHttpRequest.prototype.setRequestHeader XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__ocMethod = String(method || "GET").toUpperCase() this.__ocUrl = String(url) this.__ocHeaders = {} return origXHROpen.call(this, method, url, ...rest) } XMLHttpRequest.prototype.setRequestHeader = function (name, value) { this.__ocHeaders = this.__ocHeaders || {} this.__ocHeaders[name] = value return origXHRSetHeader.call(this, name, value) } XMLHttpRequest.prototype.send = function (body) { maybeCapture(this.__ocMethod || "GET", this.__ocUrl, this.__ocHeaders || {}, body == null ? null : String(body)) return origXHRSend.call(this, body) } function looksLikeUsage(v) { return Array.isArray(v) && v.length > 0 && "inputTokens" in v[0] && "model" in v[0] && "timeCreated" in v[0] } function extractRows(json) { if (looksLikeUsage(json)) return json const stack = [json] while (stack.length) { const cur = stack.pop() if (!cur || typeof cur !== "object") continue if (looksLikeUsage(cur)) return cur for (const v of Object.values(cur)) stack.push(v) } return null } const norm = (r) => { let plan = r.enrichment if (typeof plan === "string") { try { plan = JSON.parse(plan).plan } catch { plan = null } } else if (plan && typeof plan === "object") plan = plan.plan return { timeCreated: r.timeCreated ?? null, sessionID: r.sessionID ?? null, model: r.model ?? "(unknown)", inputTokens: r.inputTokens ?? 0, cacheReadTokens: r.cacheReadTokens ?? 0, cacheWriteTokens: (r.cacheWrite5mTokens ?? 0) + (r.cacheWrite1hTokens ?? 0), outputTokens: r.outputTokens ?? 0, reasoningTokens: r.reasoningTokens ?? 0, costUSD: r.cost != null ? r.cost / 1e8 : null, plan, keyID: r.keyID ?? null, source: "network", } } function patchPageArg(raw, page) { if (raw == null) return null let text = String(raw) try { text = decodeURIComponent(text) } catch {} try { const parsed = JSON.parse(text) if (parsed?.t?.t === 9 && Array.isArray(parsed.t.a) && parsed.t.a.length >= 2) { const last = parsed.t.a[parsed.t.a.length - 1] if (last?.t === 0 && typeof last.s === "number") { last.s = page return JSON.stringify(parsed) } } if (Array.isArray(parsed) && parsed.length >= 2 && typeof parsed[0] === "string") { parsed[parsed.length - 1] = page return JSON.stringify(parsed) } } catch {} const m = text.match(/^(\$R\[\d+\]=\["wrk_[^"]+",)\d+(\])/) if (m) return text.replace(m[0], `${m[1]}${page}${m[2]}`) return null } function pageBody(body, page) { return patchPageArg(body, page) } function pageUrl(url, page) { const u = new URL(url, location.origin) const argsRaw = u.searchParams.get("args") if (!argsRaw) return null let decoded = argsRaw try { decoded = decodeURIComponent(argsRaw) } catch {} const patched = patchPageArg(decoded, page) if (!patched) return null u.searchParams.set("args", encodeURIComponent(patched)) return u.toString() } function parsePayload(text) { if (!text) return null try { const json = JSON.parse(text) if (json && typeof json === "object") return json } catch {} const src = String(text).trim() if (src.includes("$R")) { try { const self = { $R: {} } const code = src.replace(/^;[0-9a-fA-Fx]+;/, "") Function("self", "$R", code)(self, self.$R) return self.$R } catch {} const m = src.match(/\$R\[0\]=(.+)$/) if (m) { try { return Function(`"use strict"; return (${m[1]})`)() } catch {} } } return null } function apiKeyQueryArgs(workspaceID) { return encodeURIComponent( JSON.stringify({ t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: workspaceID }], o: 0 }, f: 31, m: [], }), ) } function collectKnownKeyIDs(detail, summary) { return [...new Set([...detail.map((r) => r.keyID), ...summary.map((r) => r.keyID)].filter(Boolean))] } function keyDisplayName(keyID, keyNames) { return keyID ? keyNames?.[keyID] || "" : "" } const keyLabelCache = new WeakMap() function keyLabel(keyID, plan = "", keyNames = {}) { if (!keyID) return t("keyUnknown") let cache = keyLabelCache.get(keyNames) if (!cache) { cache = new Map() keyLabelCache.set(keyNames, cache) } const ck = `${keyID}|${plan || ""}` let label = cache.get(ck) if (label === undefined) { const name = keyDisplayName(keyID, keyNames) const suffix = keyID.slice(-6) const base = name ? `${name} · ${suffix}` : suffix label = plan ? `${base} (${plan})` : base cache.set(ck, label) } return label } function extractApiKeyNames(payload, knownIDs = []) { const known = new Set(knownIDs) const names = {} const seen = new Set() const stack = [payload] while (stack.length) { const cur = stack.pop() if (!cur || typeof cur !== "object" || seen.has(cur)) continue seen.add(cur) if (Array.isArray(cur)) { for (const item of cur) stack.push(item) continue } const candidateID = (typeof cur.id === "string" && cur.id) || (typeof cur.keyID === "string" && cur.keyID) || (typeof cur.keyId === "string" && cur.keyId) || (typeof cur.apiKeyID === "string" && cur.apiKeyID) || (typeof cur.apiKeyId === "string" && cur.apiKeyId) const candidateName = (typeof cur.name === "string" && cur.name) || (typeof cur.label === "string" && cur.label) || (typeof cur.title === "string" && cur.title) if (candidateID && candidateName && known.has(candidateID)) { names[candidateID] = candidateName.trim() } for (const value of Object.values(cur)) stack.push(value) } return names } async function fetchApiKeyNames(knownIDs) { if (!knownIDs.length) return {} const url = `${location.origin}/_server?id=${API_KEYS_SERVER_ID}&args=${apiKeyQueryArgs(WS_ID)}` const res = await origFetch(url, { method: "GET", headers: { accept: "*/*", "x-server-id": API_KEYS_SERVER_ID, "x-server-instance": API_KEYS_SERVER_INSTANCE, }, credentials: "include", }) if (!res.ok) throw new Error("获取 key 名称失败: HTTP " + res.status) const payload = parsePayload(await res.text()) const names = extractApiKeyNames(payload, knownIDs) if (!Object.keys(names).length) throw new Error("接口已返回,但未解析到 API key 名称") return names } async function refreshApiKeyNames() { const wsData = await getWorkspaceData() const knownIDs = collectKnownKeyIDs(wsData.detail, wsData.summary) if (!knownIDs.length) { setStatus(null, t("msgNoKeys")) return } setStatus(null, t("msgKeyRefreshing")) const names = await fetchApiKeyNames(knownIDs) const next = { ...wsData, keyNames: { ...(wsData.keyNames || {}), ...names }, lastKeySync: Date.now(), lastAccess: Date.now() } await saveWorkspaceData(next) renderPanel(next.detail, next.summary, next.keyNames) setStatus(null, t("msgKeyRefreshed", Object.keys(names).length)) } let fetchGate = Promise.resolve() let lastFetchAt = 0 function throttleFetch() { fetchGate = fetchGate.then(async () => { const wait = getPageGapMs() - (Date.now() - lastFetchAt) if (wait > 0) await sleep(wait) lastFetchAt = Date.now() }) return fetchGate } async function fetchPage(page, signal) { await throttleFetch() throwIfAborted(signal) if (!observed) throw new Error("未捕获到服务端请求") const headers = {} for (const [k, v] of Object.entries(observed.headers || {})) { if (/^(content-length|host|connection|transfer-encoding)$/i.test(k)) continue headers[k] = v } let url = observed.url let method = observed.method || "POST" let body = observed.body if (observed.kind === "server") { if (observed.method === "GET") { url = pageUrl(observed.url, page) || observed.url method = "GET" body = null } else { method = "POST" body = pageBody(observed.body, page) if (!body) throw new Error("无法构造分页请求") } } else { let args try { const parsed = JSON.parse(observed.body || "") args = Array.isArray(parsed) ? parsed : parsed.args } catch { args = null } if (!Array.isArray(args)) throw new Error("无法解析请求参数") method = "POST" body = JSON.stringify({ args: [args[0], page] }) } const res = await origFetch(url, { method, headers, body: body ?? undefined, signal: signal ?? undefined }) if (!res.ok) throw new Error("HTTP " + res.status) const json = parsePayload(await res.text()) const rows = extractRows(json) return (rows || []).map(norm) } async function ensureObserved() { if (observed) return true await waitFor(() => observed, 1500) if (observed) return true const clickNext = async () => { const btns = $qa('[data-slot="pagination"] button') if (btns.length < 2 || btns[1].disabled) return false const first = $q('[data-slot="usage-table-element"] tbody tr')?.textContent ?? "" btns[1].click() await waitFor(() => observed || $q('[data-slot="usage-table-element"] tbody tr')?.textContent !== first, 5000) return !!observed } if (await clickNext()) return true const btns = $qa('[data-slot="pagination"] button') if (btns.length >= 2 && !btns[0].disabled) { btns[0].click() await sleep(400) if (await clickNext()) return true } await waitFor(() => observed, 3000) return !!observed } async function fetchWithRetry(page, tries = 3, signal) { for (let i = 0; i < tries; i++) { try { return await fetchPage(page, signal) } catch (e) { if (isAbort(e) || signal?.aborted) throw e if (i === tries - 1) throw e await sleep(400 * (i + 1)) } } } // ---------- DOM 兜底 ---------- const domKey = (r) => (r.timeCreated ? `t:${r.timeCreated}:${r.sessionID || ""}` : `c:${r.model || ""}|${r.sessionID || ""}|${r.inputTokens ?? ""}|${r.outputTokens ?? ""}`) async function readCell(cell) { const wrap = cell ? $q('[data-slot="tokens-with-breakdown"]', cell) : null if (!wrap) return { total: 0, comps: [] } const totalSpan = $q(":scope > span", wrap) const total = NUM(totalSpan?.textContent) const btn = $q('[data-slot="breakdown-button"]', wrap) if (!btn) return { total, comps: [] } btn.click() await waitFor(() => !!$q('[data-slot="breakdown-popup"]', wrap), 1500) const comps = $qa('[data-slot="breakdown-row"]', wrap).map((r) => ({ label: $q('[data-slot="breakdown-label"]', r)?.textContent?.trim() ?? "", value: NUM($q('[data-slot="breakdown-value"]', r)?.textContent), })) totalSpan?.click() await sleep(30) return { total, comps } } async function domScrapePage() { const rows = [] for (const tr of $qa('[data-slot="usage-table-element"] tbody tr')) { const dateEl = $q('[data-slot="usage-date"]', tr) let timeCreated = null const t = Date.parse(dateEl?.getAttribute("title") || "") if (!Number.isNaN(t)) timeCreated = t const model = $q('[data-slot="usage-model"]', tr)?.textContent?.trim() ?? "" const sessionID = $q('[data-slot="usage-session"]', tr)?.textContent?.trim() ?? "" const costText = $q('[data-slot="usage-cost"]', tr)?.textContent?.trim() ?? "" const cells = $qa('[data-slot="usage-tokens"]', tr) const input = await readCell(cells[0]) const output = await readCell(cells[1]) const ip = input.comps, op = output.comps const r = { timeCreated, sessionID, model, inputTokens: ip[0]?.value ?? 0, cacheReadTokens: ip[1]?.value ?? 0, cacheWriteTokens: ip[2]?.value ?? 0, outputTokens: op[0]?.value ?? 0, reasoningTokens: op[1]?.value ?? 0, costUSD: (() => { const m = costText.match(/-?\$?([\d.]+)/) return m ? parseFloat(m[1]) : null })(), plan: /\(go\)|subscription|sub/i.test(costText) ? "lite" : null, keyID: null, source: "dom", } r.key = domKey(r) rows.push(r) } return rows } async function domScrapeAll(onProgress, signal) { const all = [] let pageNo = 0 let staleRounds = 0 let capped = false let aborted = false for (;;) { if (signal?.aborted) { aborted = true break } all.push(...(await domScrapePage())) onProgress?.(all.length) const btns = $qa('[data-slot="pagination"] button') if (btns.length < 2 || btns[1].disabled) break const first = $q('[data-slot="usage-table-element"] tbody tr')?.textContent ?? "" btns[1].click() await waitFor(() => { const t = $q('[data-slot="usage-table-element"] tbody tr')?.textContent ?? "" return t !== first }, 3000) if ($q('[data-slot="usage-table-element"] tbody tr')?.textContent === first) staleRounds++ else staleRounds = 0 pageNo++ if (pageNo > 10000 || staleRounds > 3) { capped = pageNo > 10000 break } } return { rows: all, capped, aborted } } // ---------- 并发拉页 ---------- // 运行级中止:新运行自动中止旧运行;取消按钮/超时/面板重建共用 abortActiveRun let activeAbort = null const abortActiveRun = () => activeAbort?.abort() const isAbort = (e) => e && (e.name === "AbortError" || e.name === "CancelError") const throwIfAborted = (signal) => { if (signal?.aborted) { const e = new Error("aborted") e.name = "AbortError" throw e } } async function withRunAbort(fn) { const ac = new AbortController() if (activeAbort) activeAbort.abort() activeAbort = ac try { return await fn(ac.signal) } finally { if (activeAbort === ac) activeAbort = null } } async function fetchPages(onProgress, minTime, signal) { const concurrency = minTime > 0 ? 1 : CONC const results = [] let next = 0 let end = false let stalled = 0 let capped = false let aborted = false async function worker() { while (!end) { if (signal?.aborted) { aborted = true end = true return } if (next >= MAX_PAGES) { capped = true end = true return } const p = next++ let rows try { rows = await fetchWithRetry(p, 3, signal) } catch (e) { if (isAbort(e) || signal?.aborted) { aborted = true end = true return } rows = null } if (!rows || rows.length === 0) { end = true continue } if (rows.length < PAGE_SIZE) end = true const before = results.length if (minTime > 0) { const fresh = rows.filter((r) => !(r.timeCreated && r.timeCreated <= minTime)) if (fresh.length) results.push(...fresh) if (rows[0]?.timeCreated && rows[0].timeCreated <= minTime) end = true } else { results.push(...rows) } if (results.length === before) { if (++stalled > 5) end = true } else stalled = 0 onProgress?.(results.length) } } await Promise.all(Array.from({ length: concurrency }, worker)) return { rows: results, capped, aborted } } // ---------- 聚合 / 分层 ---------- const keyOf = (r) => (r.timeCreated ? `t:${r.timeCreated}:${r.sessionID || ""}` : domKey(r)) // 按 UTC 日桶缓存:同一自然日只格式化一次(输出与 toISOString().slice 完全一致) const dayCache = new Map() const dateKey = (r) => { if (!r.timeCreated) return t("dateUnknown") const day = Math.floor(r.timeCreated / 86400000) let d = dayCache.get(day) if (!d) { d = new Date(day * 86400000).toISOString().slice(0, 10) dayCache.set(day, d) } return d } // 明细 → 汇总(超过窗口的折叠进 summary) function rollup(rows, summary, cutoff) { const sumMap = new Map() for (const s of summary) sumMap.set(s.key, s) const detail = [] for (const r of rows) { if (r.timeCreated && r.timeCreated < cutoff) { const d = dateKey(r) const k = `${d}|${r.model || ""}|${r.plan || ""}|${r.keyID || ""}` let a = sumMap.get(k) if (!a) { a = { key: k, date: d, model: r.model, plan: r.plan || "", keyID: r.keyID || "", requests: 0, inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0, reasoningTokens: 0, costUSD: 0 } sumMap.set(k, a) } a.requests++ a.inputTokens += r.inputTokens a.cacheReadTokens += r.cacheReadTokens a.cacheWriteTokens += r.cacheWriteTokens a.outputTokens += r.outputTokens a.reasoningTokens += r.reasoningTokens a.costUSD += r.costUSD ?? 0 } else detail.push(r) } return { detail, summary: [...sumMap.values()] } } function aggregate(rows, keyOf2) { const map = new Map() for (const r of rows) { const k = keyOf2(r) || "(unknown)" let a = map.get(k) if (!a) { a = { key: k, requests: 0, inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0, reasoningTokens: 0, costUSD: 0, _t: 0 } map.set(k, a) } const it = r.inputTokens ?? 0 const cr = r.cacheReadTokens ?? 0 const cw = r.cacheWriteTokens ?? 0 const ot = r.outputTokens ?? 0 a.requests++ a.inputTokens += it a.cacheReadTokens += cr a.cacheWriteTokens += cw a.outputTokens += ot a.reasoningTokens += r.reasoningTokens ?? 0 a.costUSD += r.costUSD ?? 0 a._t += it + cr + ot } const arr = [...map.values()] arr.sort((a, b) => b._t - a._t) for (const o of arr) delete o._t return arr } // 汇总条目直接累加(已带 requests 计数) function sumAggregate(summary, keyOf2) { const map = new Map() for (const s of summary) { const k = keyOf2(s) || "(unknown)" let a = map.get(k) if (!a) { a = { key: k, requests: 0, inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0, reasoningTokens: 0, costUSD: 0, _t: 0 } map.set(k, a) } const it = s.inputTokens ?? 0 const cr = s.cacheReadTokens ?? 0 const ot = s.outputTokens ?? 0 a.requests += s.requests ?? 0 a.inputTokens += it a.cacheReadTokens += cr a.cacheWriteTokens += s.cacheWriteTokens ?? 0 a.outputTokens += ot a.reasoningTokens += s.reasoningTokens ?? 0 a.costUSD += s.costUSD ?? 0 a._t += it + cr + ot } const arr = [...map.values()] arr.sort((a, b) => b._t - a._t) for (const o of arr) delete o._t return arr } function mergeAgg(a, b) { const m = new Map() const totals = new Map() for (const x of a) { m.set(x.key, x) totals.set(x.key, x.inputTokens + x.cacheReadTokens + x.outputTokens) } for (const x of b) { const cur = m.get(x.key) if (cur) { for (const f of AGG_FIELDS) cur[f] += x[f] ?? 0 totals.set(x.key, totals.get(x.key) + x.inputTokens + x.cacheReadTokens + x.outputTokens) } else { m.set(x.key, { ...x }) totals.set(x.key, x.inputTokens + x.cacheReadTokens + x.outputTokens) } } const arr = [...m.values()] arr.sort((p, q) => totals.get(q.key) - totals.get(p.key)) return arr } function rawRows(rows, keyNames = {}) { return rows .map((r) => { const iso = r.timeCreated ? new Date(r.timeCreated).toISOString() : "" return { timeUTC: iso, date: iso ? iso.slice(0, 10) : "", sessionID: r.sessionID, model: r.model, inputTokens: r.inputTokens, cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens, outputTokens: r.outputTokens, reasoningTokens: r.reasoningTokens, costUSD: r.costUSD?.toFixed(6) ?? "", plan: r.plan ?? "", keyID: r.keyID ?? "", keyName: keyDisplayName(r.keyID, keyNames), source: r.source, } }) .sort((a, b) => (a.timeUTC < b.timeUTC ? 1 : -1)) } function toCSV(rows, cols) { if (!rows.length) return "" const esc = (v) => { if (v == null) return "" if (typeof v === "number") return String(v) v = String(v) return /[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v } return [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\n") } function download(name, content) { const blob = new Blob(["\ufeff" + content], { type: "text/csv;charset=utf-8" }) const a = document.createElement("a") a.href = URL.createObjectURL(blob) a.download = name a.style.display = "none" document.body.appendChild(a) a.click() document.body.removeChild(a) setTimeout(() => URL.revokeObjectURL(a.href), 1000) } function exportXLSX(detail, summary, tag = "", keyNames = {}) { if (typeof XLSX === "undefined") return false const wb = XLSX.utils.book_new() const add = (name, data, cols) => { const ws = XLSX.utils.aoa_to_sheet([cols, ...data.map((r) => cols.map((c) => r[c]))]) XLSX.utils.book_append_sheet(wb, ws, name.slice(0, 31)) } const raw = rawRows(detail, keyNames) add("raw", raw, Object.keys(raw[0] || {})) add("by-model", mergedAggs(detail, summary, (s) => s.model, (r) => r.model), AGG_COLS) add("by-date", mergedAggs(detail, summary, (s) => s.date, dateKey), AGG_COLS) add("by-key", mergedAggs(detail, summary, (s) => keyLabel(s.keyID, s.plan, keyNames), (r) => keyLabel(r.keyID, r.plan, keyNames)), AGG_COLS) add("by-plan", mergedAggs(detail, summary, (s) => s.plan || "pay-as-you-go", (r) => r.plan || "pay-as-you-go"), AGG_COLS) XLSX.writeFile(wb, `go-usage${tag}-${new Date().toISOString().slice(0, 10)}.xlsx`) return true } function isoDateLocal(d) { const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, "0") const day = String(d.getDate()).padStart(2, "0") return `${y}-${m}-${day}` } function parseDateInput(val, endOfDay = false) { if (!val) return null const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(val)) if (!m) return null const t = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) // 与 dateKey/汇总键一致采用 UTC 日界:区间筛选、日期标签、汇总口径统一,结果与用户时区无关 return endOfDay ? t + 86400000 - 1 : t } const parseDateCache = new Map() function filterByRange(detail, summary, fromMs, toMs) { const fd = detail.filter((r) => { if (!r.timeCreated) return !fromMs && !toMs if (fromMs && r.timeCreated < fromMs) return false if (toMs && r.timeCreated > toMs) return false return true }) const fs = summary.filter((s) => { if (!parseDateCache.has(s.date)) parseDateCache.set(s.date, Date.parse(s.date)) const t = parseDateCache.get(s.date) if (Number.isNaN(t)) return !fromMs && !toMs if (fromMs && t < fromMs) return false if (toMs && t > toMs) return false return true }) return { detail: fd, summary: fs } } function exportTag(fromVal, toVal) { if (fromVal && toVal) return `_${fromVal}_${toVal}` if (fromVal) return `_from-${fromVal}` if (toVal) return `_to-${toVal}` return "_all" } async function doExport(format = "csv") { const fromEl = $q("#oc-export-from") const toEl = $q("#oc-export-to") const fromVal = fromEl?.value || "" const toVal = toEl?.value || "" const fromMs = parseDateInput(fromVal) const toMs = parseDateInput(toVal, true) const wsData = await getWorkspaceData() const keyNames = wsData.keyNames || {} const { detail, summary } = filterByRange(wsData.detail, wsData.summary || [], fromMs, toMs) if (!detail.length && !summary.length) { setStatus(null, t("msgExportEmpty")) return } const tag = exportTag(fromVal, toVal) const ts = new Date().toISOString().slice(0, 10) if (format === "xlsx") { if (!exportXLSX(detail, summary, tag, keyNames)) { setStatus(null, t("msgNoSheetjs")) return } } else { const raw = rawRows(detail, keyNames) download(`go-usage-raw${tag}-${ts}.csv`, toCSV(raw, Object.keys(raw[0] || {}))) download(`go-usage-by-model${tag}-${ts}.csv`, toCSV(mergedAggs(detail, summary, (s) => s.model, (r) => r.model), AGG_COLS)) download(`go-usage-by-date${tag}-${ts}.csv`, toCSV(mergedAggs(detail, summary, (s) => s.date, dateKey), AGG_COLS)) download(`go-usage-by-key${tag}-${ts}.csv`, toCSV(mergedAggs(detail, summary, (s) => keyLabel(s.keyID, s.plan, keyNames), (r) => keyLabel(r.keyID, r.plan, keyNames)), AGG_COLS)) download(`go-usage-by-plan${tag}-${ts}.csv`, toCSV(mergedAggs(detail, summary, (s) => s.plan || "pay-as-you-go", (r) => r.plan || "pay-as-you-go"), AGG_COLS)) } setStatus(null, t("msgExportDone", detail.length, summary.length, fromVal || "…", toVal || "…")) } function setExportRange(days) { const to = new Date() const from = new Date() if (days === "all") { from.setFullYear(2000, 0, 1) } else { from.setDate(from.getDate() - Number(days)) } const fromEl = $q("#oc-export-from") const toEl = $q("#oc-export-to") if (fromEl) fromEl.value = isoDateLocal(from) if (toEl) toEl.value = isoDateLocal(to) $qa(".oc-export-presets button").forEach((b) => b.classList.toggle("oc-active", b.dataset.range === String(days))) } const mergedAggs = (detail, summary, sumKey, detKey) => mergeAgg(sumAggregate(summary, sumKey), aggregate(detail, detKey)) // 明细最大时间戳:循环实现,避免大数组 spread 触发调用栈溢出(RangeError) function maxTimeCreated(rows) { let max = 0 for (const r of rows) { const t = r.timeCreated || 0 if (t > max) max = t } return max } // HTML 转义:面板渲染服务端/用户数据时使用,防标签注入(XSS) const escHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])) // 面板统计的纯数值计算(renderPanel 消费):单遍成本窗口、循环极值,避免多趟 filter/spread function computePanelStats(detail, summary, keyNames, settings, now) { const sumF = (list, f) => list.reduce((a, r) => a + (r[f] ?? 0), 0) const sumCost = (list) => list.reduce((a, r) => a + (r.costUSD ?? 0), 0) // GO 限额始终用完整 detail 滚动窗口(单遍同时算 5h / 7d / 30d) let cost5h = 0 let cost7d = 0 let cost30dQuota = 0 const c5 = now - 5 * 3600e3 const c7 = now - 7 * 24 * 3600e3 for (const r of detail) { const c = r.costUSD ?? 0 cost30dQuota += c if (r.timeCreated) { if (r.timeCreated > c5) cost5h += c if (r.timeCreated > c7) cost7d += c } } // 概览统计区间(与限额独立) let preset = settings.statPresetDays if (preset !== 7 && preset !== 30 && preset !== "all") preset = 30 let viewDetail = detail let viewSummary = [] let winStart = now - WINDOW_MS let winLabel = t("statWindow30d") if (preset === 7) { const cut = now - 7 * 24 * 3600e3 viewDetail = detail.filter((r) => r.timeCreated && r.timeCreated >= cut) viewSummary = [] winStart = cut winLabel = t("statWindow7d") } else if (preset === 30) { viewDetail = detail viewSummary = [] winStart = now - WINDOW_MS winLabel = t("statWindow30d") } else { viewDetail = detail viewSummary = summary winStart = null winLabel = t("statWindowAll") } const viewCost = sumCost(viewDetail) + sumF(viewSummary, "costUSD") const total = { requests: viewDetail.length + sumF(viewSummary, "requests"), inputTokens: sumF(viewDetail, "inputTokens") + sumF(viewSummary, "inputTokens"), cacheReadTokens: sumF(viewDetail, "cacheReadTokens") + sumF(viewSummary, "cacheReadTokens"), outputTokens: sumF(viewDetail, "outputTokens") + sumF(viewSummary, "outputTokens"), } const topByCost = (arr, n) => [...arr].sort((a, b) => b.costUSD - a.costUSD).slice(0, n) const byModel = topByCost(mergedAggs(viewDetail, viewSummary, (s) => s.model, (r) => r.model), settings.topModelCount) const byKey = topByCost(mergedAggs(viewDetail, viewSummary, (s) => keyLabel(s.keyID, s.plan, keyNames), (r) => keyLabel(r.keyID, r.plan, keyNames)), settings.topKeyCount) const byPlan = mergedAggs(viewDetail, viewSummary, (s) => s.plan || "pay-as-you-go", (r) => r.plan || "pay-as-you-go") // 极值:仅统计带真实时间戳的行(跳过 null,避免 0 时间戳污染为 1970-01-01) let minTC = null let maxTC = null for (const r of viewDetail) { if (!r.timeCreated) continue if (minTC === null || r.timeCreated < minTC) minTC = r.timeCreated if (maxTC === null || r.timeCreated > maxTC) maxTC = r.timeCreated } return { cost5h, cost7d, cost30dQuota, viewCost, total, byModel, byKey, byPlan, minTC, maxTC, viewDetail, viewSummary, preset, winStart, winLabel } } // ---------- 主流程 ---------- async function run(mode, btn, opts = {}) { const { downloadFiles = false } = opts return withRunAbort(async (signal) => { const wsData = await getWorkspaceData() const now = Date.now() const cutoff = now - WINDOW_MS const maxStored = maxTimeCreated(wsData.detail) const mergedMap = new Map(wsData.detail.map((r) => [keyOf(r), r])) let source = null let capped = false let aborted = false let fetchAll = null try { if (await ensureObserved()) fetchAll = (onP) => fetchPages(onP, mode === "incremental" ? maxStored : 0, signal) } catch {} let rows = [] if (fetchAll) { source = "network" const res = await fetchAll((n) => setStatus(btn, t(mode === "incremental" ? "msgFetchProgressInc" : "msgFetchProgressFull", n))) rows = res.rows capped = res.capped aborted = res.aborted } else { source = "dom" setStatus(btn, t("msgDomFallback")) const res = await domScrapeAll((n) => setStatus(btn, t("msgDomProgress", n)), signal) rows = res.rows capped = res.capped aborted = res.aborted } if (!Array.isArray(rows)) rows = [] let added = 0 for (const r of rows) { const k = keyOf(r) if (!mergedMap.has(k)) { mergedMap.set(k, r) added++ } } const merged = [...mergedMap.values()].sort((a, b) => (b.timeCreated || 0) - (a.timeCreated || 0)) const dupTotal = rows.length - added // 分层:窗口内保留明细,窗口外折叠进汇总;取消/达上限也保存已抓部分(断点续传) const { detail, summary } = rollup(merged, wsData.summary || [], cutoff) // 取消时保留原 lastSync,避免抑制下次自动同步 await saveWorkspaceData({ ...wsData, id: WS_ID, detail, summary, lastSync: aborted ? wsData.lastSync : now, lastAccess: now }) renderPanel(detail, summary, wsData.keyNames || {}) if (downloadFiles) await doExport("csv") const btns = $qa('[data-slot="pagination"] button') if (btns.length >= 2 && !btns[0].disabled) btns[0].click() if (aborted) setStatus(btn, t("msgCancelled", added)) else setStatus( btn, t(mode === "incremental" ? "msgDoneInc" : "msgDoneFull", source, added, detail.length, summary.length) + (dupTotal > 0 ? t("msgDedup", dupTotal) : "") + (capped ? t("msgPageCap", MAX_PAGES) : ""), ) }) } // ---------- 面板 ---------- function renderPanel(detail, summary, keyNames = {}) { const panel = $q("#oc-go-export-panel") const body = $q("#oc-go-export-body") if (!panel) return const settings = loadSettings() const foldState = {} panel.querySelectorAll("details.oc-fold").forEach((el, i) => { foldState[el.querySelector("summary")?.textContent?.trim() || i] = el.open }) const scrollTop = body?.scrollTop ?? 0 const now = Date.now() const stats = computePanelStats(detail, summary, keyNames, settings, now) const { cost5h, cost7d, cost30dQuota, viewCost, total, byModel, byKey, byPlan, minTC, maxTC, viewDetail, viewSummary, preset, winStart, winLabel } = stats const fmtT = (n) => { if (n == null) return "-" if (n >= 1e6) return (n / 1e6).toFixed(2) + "M" if (n >= 1e3) return (n / 1e3).toFixed(1) + "k" return String(n) } const fmtD = fmtDate const windowText = winStart != null ? `${fmtD(winStart)} ~ ${fmtD(now)}(${winLabel})` : winLabel // bar 统一以费用为主指标排序和宽度,secondary 为右侧标注文字 const barRow = (label, cost, maxCost, secondary = "") => `
${escHtml(label)} ${cost != null ? "$" + cost.toFixed(2) : ""}
${secondary}
` const quotaRow = (label, spent, limit) => { const pct = limit ? Math.min(100, (spent / limit) * 100) : 0 const cls = pct >= 90 ? "oc-quota-warn" : pct >= 70 ? "oc-quota-mid" : "" return `
${label}$${spent.toFixed(2)} / $${limit}
` } const dimFolds = [] const dimOpen = settings.dimensionsOpen ? " open" : "" // 三个维度统一按费用降序,bar 宽度 = 费用占比 if (byModel.length) { const maxCost = byModel[0].costUSD dimFolds.push(`
${t("dimByModel", byModel.length)}
${byModel.map((m) => barRow(m.key, m.costUSD, maxCost, fmtT(m.inputTokens + m.cacheReadTokens + m.outputTokens) + t("unitTok"))).join("")}
`) } if (byKey.length) { const maxCost = byKey[0].costUSD dimFolds.push(`
${t("dimByKey", byKey.length)}
${byKey.map((m) => barRow(m.key, m.costUSD, maxCost, m.requests.toLocaleString() + t("unitReqs"))).join("")}
`) } if (byPlan.length) { const planSorted = [...byPlan].sort((a, b) => b.costUSD - a.costUSD) const maxCost = planSorted[0].costUSD dimFolds.push(`
${t("dimByPlan", planSorted.length)}
${planSorted.map((m) => barRow(m.key, m.costUSD, maxCost, fmtT(m.inputTokens + m.cacheReadTokens + m.outputTokens) + t("unitTok") + " · " + m.requests.toLocaleString() + t("unitReqs"))).join("")}
`) } const overviewOpen = settings.overviewOpen ? " open" : "" const presetStr = String(preset) panel.innerHTML = `
${t("statOverview")}
${t("statWindowLabel")}${windowText}
${t("statRangeLabel")}${minTC ? `${fmtD(minTC)} ~ ${fmtD(maxTC)}` : t("statEmptyDetail")}${viewSummary.length ? ` · ${t("statSummaryGroups", viewSummary.length)}` : ""}
${t("statTotalRequests", total.requests.toLocaleString())} $${viewCost.toFixed(2)}
Input
${fmtT(total.inputTokens)}
Cache
${fmtT(total.cacheReadTokens)}
Output
${fmtT(total.outputTokens)}
5h / 7d
$${cost5h.toFixed(2)} / $${cost7d.toFixed(2)}
${quotaRow(t("statQuota5h"), cost5h, 12)} ${quotaRow(t("statQuota7d"), cost7d, 30)} ${quotaRow(t("statQuota30d"), cost30dQuota, 60)}
${t("statFootDetail", viewDetail.length.toLocaleString(), viewSummary.length.toLocaleString())} ${t("statFootKeys", Object.keys(keyNames || {}).length)}
${dimFolds.join("") || `
${t("statEmptyDim")}
`}
` panel.querySelectorAll("details.oc-fold").forEach((el, i) => { const key = el.querySelector("summary")?.textContent?.trim() || i if (key in foldState) el.open = foldState[key] else if (key.startsWith(t("statOverview"))) el.open = settings.overviewOpen else if (el.classList.contains("oc-dim-fold")) el.open = settings.dimensionsOpen }) panel.querySelectorAll(".oc-stat-presets button").forEach((b) => { b.addEventListener("click", (e) => { e.preventDefault() e.stopPropagation() const raw = b.dataset.statRange const val = raw === "all" ? "all" : parseInt(raw, 10) saveSettings({ statPresetDays: val }) renderPanel(detail, summary, keyNames) }) }) if (body) body.scrollTop = scrollTop } // ---------- UI ---------- function injectStyles() { let s = document.getElementById("oc-go-export-style") if (!s) { s = document.createElement("style") s.id = "oc-go-export-style" document.head.appendChild(s) } s.textContent = ` #oc-go-export-root{position:fixed;bottom:20px;right:20px;z-index:9000;font-family:inherit;font-size:13px;line-height:1.4} #oc-go-export-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:8999;opacity:0;pointer-events:none;transition:opacity .18s} #oc-go-export-root.oc-mode-large.oc-open #oc-go-export-backdrop{opacity:1;pointer-events:auto} #oc-go-export-toggle{width:46px;height:46px;border-radius:23px;background:#e84c3d;color:#fff;border:1px solid rgba(255,255,255,.15);cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.35);font-weight:700;font-size:11px;letter-spacing:.02em;transition:transform .15s,background .15s} #oc-go-export-toggle:hover{background:#d44335;transform:scale(1.04)} #oc-go-export-root.oc-open #oc-go-export-toggle{background:#333;border-color:rgba(255,255,255,.2);font-size:18px;line-height:1} #oc-go-export-drawer{position:absolute;bottom:54px;right:0;width:min(340px,calc(100vw - 40px));background:#141414;border:1px solid rgba(255,255,255,.12);border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,.5);overflow:hidden;opacity:0;pointer-events:none;transform:translateY(8px) scale(.98);transform-origin:bottom right;transition:opacity .18s,transform .18s} #oc-go-export-root.oc-mode-compact.oc-open #oc-go-export-drawer{opacity:1;pointer-events:auto;transform:none;max-height:min(88vh,680px);display:flex;flex-direction:column} #oc-go-export-root.oc-mode-large #oc-go-export-drawer{position:fixed;top:50%;left:50%;bottom:auto;right:auto;width:min(780px,calc(100vw - 24px));max-height:90vh;transform:translate(-50%,-50%) scale(.98);transform-origin:center;z-index:9001} #oc-go-export-root.oc-mode-large.oc-open #oc-go-export-drawer{opacity:1;pointer-events:auto;transform:translate(-50%,-50%);display:flex;flex-direction:column} #oc-go-export-root.oc-busy #oc-go-export-info{color:#e84c3d} .oc-drawer-head{flex-shrink:0} #oc-go-export-body-wrap{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden} #oc-go-export-sidebar{display:flex;flex-direction:column;flex-shrink:0} .oc-actions,#oc-go-export-info,.oc-export-block,.oc-settings-block{flex-shrink:0} #oc-go-export-body{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding-right:4px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.25) rgba(255,255,255,.06)} #oc-go-export-body::-webkit-scrollbar{width:6px} #oc-go-export-body::-webkit-scrollbar-track{background:rgba(255,255,255,.06);border-radius:99px;margin:4px 0} #oc-go-export-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.28);border-radius:99px} #oc-go-export-body::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.4)} .oc-export-block{padding:0 12px;border-top:1px solid rgba(255,255,255,.08);border-bottom:1px solid rgba(255,255,255,.08)} .oc-export-block.oc-fold>summary{padding:10px 0} .oc-export-inner{padding-bottom:10px} .oc-export-title{display:none} .oc-export-presets{display:flex;gap:4px;margin-bottom:6px} .oc-export-presets button{flex:1;padding:4px 0;font-size:10px;color:#ccc;background:#222;border:1px solid rgba(255,255,255,.08);border-radius:5px;cursor:pointer;font-family:inherit;white-space:nowrap} .oc-export-presets button.oc-active{color:#fff;background:#333;border-color:rgba(232,76,61,.5)} .oc-stat-presets{display:flex;gap:4px;margin-bottom:8px} .oc-stat-presets button{flex:1;padding:5px 0;font-size:10.5px;color:#ccc;background:#222;border:1px solid rgba(255,255,255,.08);border-radius:5px;cursor:pointer;font-family:inherit;white-space:nowrap} .oc-stat-presets button.oc-active{color:#fff;background:#333;border-color:rgba(232,76,61,.5)} .oc-stat-presets button:hover{background:#2a2a2a;color:#eee} .oc-export-dates{display:flex;align-items:center;gap:4px;margin-bottom:6px} .oc-export-dates input{flex:1;min-width:0;padding:4px 6px;font-size:10px;color:#eee;background:#1a1a1a;border:1px solid rgba(255,255,255,.12);border-radius:5px;font-family:inherit} .oc-export-dates span{color:#666;font-size:10px} .oc-export-actions{display:grid;grid-template-columns:1fr 1fr;gap:6px} .oc-export-actions button{padding:6px 8px;font-size:11px;font-weight:600;color:#fff;background:#2a2a2a;border:1px solid rgba(255,255,255,.08);border-radius:6px;cursor:pointer;font-family:inherit;white-space:nowrap} .oc-export-actions button:hover{background:#363636} .oc-export-actions button.oc-primary{background:#e84c3d;border-color:transparent} .oc-export-actions button.oc-primary:hover{background:#d44335} #oc-go-export-panel{padding:0 12px 10px;font-size:12px} .oc-fold{border-top:1px solid rgba(255,255,255,.06)} .oc-fold>summary{padding:10px 0;min-height:36px;font-size:12px;color:#ccc;cursor:pointer;user-select:none;list-style:none;display:flex;align-items:center;justify-content:space-between;gap:8px;font-weight:600} .oc-fold>summary::-webkit-details-marker{display:none} .oc-fold>summary::after{content:"▾";flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;font-size:16px;line-height:1;color:#bbb;background:rgba(255,255,255,.08);border-radius:8px;transition:transform .15s,background .15s,color .15s} .oc-fold>summary:hover::after{background:rgba(255,255,255,.14);color:#fff} .oc-fold:not([open])>summary::after{transform:rotate(-90deg)} .oc-fold-body{padding-bottom:10px} .oc-drawer-head{display:flex;align-items:center;justify-content:space-between;padding:10px 12px 8px;border-bottom:1px solid rgba(255,255,255,.08);gap:8px} .oc-drawer-head b{font-size:13px;color:#fff;font-weight:600;flex:1;min-width:0} .oc-head-actions{display:flex;align-items:center;gap:2px;flex-shrink:0} .oc-head-btn{background:none;border:none;color:#888;cursor:pointer;font-size:16px;line-height:1;padding:4px 7px;border-radius:6px;min-width:32px;min-height:32px;display:flex;align-items:center;justify-content:center} .oc-head-btn:hover{color:#fff;background:rgba(255,255,255,.08)} .oc-drawer-close{font-size:18px} .oc-settings-block{padding:0 12px;border-bottom:1px solid rgba(255,255,255,.08)} .oc-settings-block.oc-fold>summary{padding:10px 0} .oc-settings-inner{padding-bottom:12px;padding-right:4px;max-height:min(380px,60vh);overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.2) transparent} .oc-settings-inner::-webkit-scrollbar{width:4px} .oc-settings-inner::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2);border-radius:99px} .oc-settings-group{margin-bottom:2px;border-radius:8px;overflow:hidden} .oc-settings-label{display:flex;align-items:center;gap:6px;font-size:10px;color:#777;font-weight:600;padding:8px 0 5px;letter-spacing:.04em;text-transform:uppercase} .oc-settings-label::before{content:"";flex:1;height:1px;background:rgba(255,255,255,.06)} .oc-settings-label::after{content:"";flex:5;height:1px;background:rgba(255,255,255,.06)} .oc-settings-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 0;font-size:11.5px;color:#d0d0d0} .oc-settings-row+.oc-settings-row{border-top:1px solid rgba(255,255,255,.04)} .oc-settings-row>span{flex:1;min-width:0;line-height:1.35} .oc-settings-row:has(.oc-settings-radios){flex-direction:column;align-items:stretch;gap:6px} .oc-settings-row:has(.oc-settings-radios)>span{flex:none;white-space:nowrap} .oc-settings-radios{display:flex;gap:3px;flex-wrap:wrap} .oc-settings-radios label{display:flex;align-items:center;gap:0;font-size:10.5px;color:#aaa;cursor:pointer;padding:4px 9px;background:rgba(255,255,255,.05);border:1px solid transparent;border-radius:6px;transition:background .1s,color .1s,border-color .1s;white-space:nowrap} .oc-settings-radios label:hover{background:rgba(255,255,255,.09);color:#ddd} .oc-settings-radios label:has(input:checked){color:#fff;background:rgba(232,76,61,.18);border-color:rgba(232,76,61,.45)} .oc-settings-radios input[type=radio]{display:none} .oc-settings-row select{padding:4px 8px;font-size:11px;color:#eee;background:#1e1e1e;border:1px solid rgba(255,255,255,.14);border-radius:6px;font-family:inherit;cursor:pointer;outline:none} .oc-settings-row select:focus{border-color:rgba(232,76,61,.5)} .oc-settings-row input[type=number]{padding:4px 6px;font-size:11px;color:#eee;background:#1e1e1e;border:1px solid rgba(255,255,255,.14);border-radius:6px;font-family:inherit;width:52px;text-align:center;outline:none} .oc-settings-row input[type=number]:focus{border-color:rgba(232,76,61,.5)} .oc-toggle{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0} .oc-toggle input{opacity:0;width:0;height:0;position:absolute} .oc-toggle-track{display:block;width:36px;height:20px;background:rgba(255,255,255,.12);border-radius:99px;cursor:pointer;transition:background .2s} .oc-toggle input:checked+.oc-toggle-track{background:#e84c3d} .oc-toggle-track::after{content:"";position:absolute;top:3px;left:3px;width:14px;height:14px;background:#fff;border-radius:50%;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.3)} .oc-toggle input:checked+.oc-toggle-track::after{transform:translateX(16px)} .oc-settings-meta{font-size:10px;color:#555;line-height:1.5;margin-top:8px;padding:7px 8px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.05);border-radius:6px;word-break:break-all} .oc-settings-advanced{margin-top:4px} .oc-settings-advanced>summary{display:flex;align-items:center;gap:6px;font-size:10.5px;color:#666;cursor:pointer;padding:5px 0;list-style:none;transition:color .1s} .oc-settings-advanced>summary:hover{color:#aaa} .oc-settings-advanced>summary::-webkit-details-marker{display:none} .oc-settings-advanced>summary::before{content:"›";font-size:13px;transition:transform .15s;display:inline-block} .oc-settings-advanced[open]>summary::before{transform:rotate(90deg)} .oc-settings-advanced>summary::after{content:"";flex:1;height:1px;background:rgba(255,255,255,.06)} .oc-mode-large .oc-stat-grid{grid-template-columns:repeat(4,1fr)} .oc-dim-grid{display:block} .oc-mode-large .oc-dim-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start} .oc-mode-large .oc-dim-grid .oc-empty{grid-column:1/-1} .oc-mode-large .oc-actions{grid-template-columns:1fr 1fr} .oc-mode-large .oc-actions button{padding:7px 8px;font-size:11px} /* 大窗口两栏布局:sidebar 左 + body 右 */ .oc-mode-large #oc-go-export-body-wrap{flex-direction:row} .oc-mode-large #oc-go-export-sidebar{width:260px;min-width:220px;border-right:1px solid rgba(255,255,255,.08);overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.2) transparent} .oc-mode-large #oc-go-export-sidebar::-webkit-scrollbar{width:4px} .oc-mode-large #oc-go-export-sidebar::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2);border-radius:99px} /* 大窗口下 export-block 无需上下边框(已在 sidebar 内) */ .oc-mode-large .oc-export-block{border-left:none;border-right:none} /* 大窗口下 body 左侧加 padding */ .oc-mode-large #oc-go-export-body{padding-left:4px} .oc-actions{display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:10px 12px 8px} .oc-actions button{padding:8px 10px;font-size:12px;font-weight:600;color:#fff;background:#2a2a2a;border:1px solid rgba(255,255,255,.08);border-radius:7px;cursor:pointer;font-family:inherit;transition:background .12s;white-space:nowrap} .oc-actions button.oc-danger{color:#f88;background:#2a1818;border-color:rgba(232,76,61,.25)} .oc-actions button.oc-danger:hover:not(:disabled){background:#3a2020} .oc-actions button.oc-danger-confirm{color:#fff;background:#e84c3d;border-color:#e84c3d;animation:oc-pulse-danger 1.5s infinite} @keyframes oc-pulse-danger{0%,100%{box-shadow:0 0 0 0 rgba(232,76,61,.4)}50%{box-shadow:0 0 0 4px rgba(232,76,61,0)}} .oc-actions .oc-span2{grid-column:1/-1} .oc-actions button:hover:not(:disabled){background:#363636} .oc-actions button:disabled{opacity:.45;cursor:not-allowed} .oc-actions button.oc-primary{background:#e84c3d;border-color:transparent} .oc-actions button.oc-primary:hover:not(:disabled){background:#d44335} #oc-go-export-info{padding:0 12px 8px;color:#999;font-size:11px;line-height:1.45;min-height:1.2em;word-break:break-word} .oc-period{margin-bottom:10px;padding:10px 10px 8px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:8px;font-size:10.5px;color:#999;line-height:1.6} .oc-period-line{display:flex;gap:8px;align-items:baseline} .oc-period-line+.oc-period-line{margin-top:4px;padding-top:4px;border-top:1px solid rgba(255,255,255,.05)} .oc-period-k{color:#666;flex-shrink:0;min-width:56px;font-size:10px;text-transform:uppercase;letter-spacing:.03em} .oc-panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} .oc-stat-total{font-size:11px;color:#888} .oc-stat-cost-pill{font-size:13px;font-weight:700;color:#f0f0f0;font-variant-numeric:tabular-nums;letter-spacing:-.01em} .oc-muted{font-size:10.5px;color:#777} .oc-stat-v-sm{font-size:11px!important} .oc-empty{padding:16px 0;text-align:center;color:#555;font-size:11px} .oc-quota-block{margin-bottom:8px;padding:10px;background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.06);border-radius:8px} .oc-quota-row{margin:7px 0} .oc-quota-row:first-of-type{margin-top:2px} .oc-quota-meta{display:flex;justify-content:space-between;gap:8px;font-size:10.5px;color:#888;margin-bottom:4px} .oc-quota-num{font-variant-numeric:tabular-nums;color:#bbb;font-weight:500} .oc-quota-track{height:6px;background:#222;border-radius:99px;overflow:hidden} .oc-quota-fill{height:100%;background:#4caf82;border-radius:99px;transition:width .25s} .oc-quota-fill.oc-quota-mid{background:#e6a817} .oc-quota-fill.oc-quota-warn{background:#e84c3d} .oc-stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:8px} .oc-stat{background:rgba(255,255,255,.04);padding:8px 10px;border-radius:8px;border:1px solid rgba(255,255,255,.06)} .oc-stat-k{font-size:10px;color:#666;margin-bottom:3px;text-transform:uppercase;letter-spacing:.03em} .oc-stat-v{font-size:14px;color:#f0f0f0;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.01em} .oc-section-label{font-size:10px;color:#666;margin:0 0 8px;text-transform:uppercase;letter-spacing:.04em} .oc-bar-row{margin:0 0 2px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.04)} .oc-bar-row:last-child{border-bottom:none;margin-bottom:0} .oc-bar-meta{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:5px;min-width:0} .oc-bar-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#ddd;font-size:11px;font-weight:500} .oc-bar-cost{flex-shrink:0;color:#888;font-size:10.5px;font-variant-numeric:tabular-nums} .oc-bar-body{display:flex;align-items:center;gap:8px} .oc-bar-track{flex:1;height:4px;background:rgba(255,255,255,.08);border-radius:99px;overflow:hidden;min-width:0} .oc-bar-fill{height:100%;background:linear-gradient(90deg,#c0392b,#e84c3d);border-radius:99px;transition:width .2s} .oc-bar-val{flex-shrink:0;min-width:48px;text-align:right;color:#999;font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap} .oc-plan-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:10.5px;color:#ccc;padding:5px 0;border-bottom:1px solid rgba(255,255,255,.04)} .oc-plan-row:last-child{border-bottom:none} .oc-plan-key{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500} .oc-plan-meta{display:flex;gap:8px;flex-shrink:0;color:#888;font-size:10px;font-variant-numeric:tabular-nums} .oc-panel-foot{display:flex;flex-wrap:wrap;gap:4px 12px;margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.06);font-size:10px;color:#555} .oc-security-note{margin:10px 12px;padding:10px 12px;background:rgba(232,76,61,.08);border:1px solid rgba(232,76,61,.35);border-radius:8px;font-size:11px;color:#f0d9d6;line-height:1.6} .oc-security-title{font-size:11px;font-weight:700;color:#f88;margin-bottom:4px} .oc-security-actions{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap} .oc-security-actions button{padding:5px 10px;font-size:11px;font-weight:600;color:#fff;background:#2a2a2a;border:1px solid rgba(255,255,255,.12);border-radius:6px;cursor:pointer;font-family:inherit} .oc-security-actions button:hover{background:#363636} .oc-settings-security{margin-top:10px;padding:8px 10px;background:rgba(232,76,61,.06);border-radius:6px;font-size:10px;color:#c9a9a5;line-height:1.5;word-break:break-all} @media(max-width:640px){#oc-go-export-root{bottom:14px;right:14px}#oc-go-export-root.oc-mode-compact #oc-go-export-drawer{width:min(300px,calc(100vw - 28px))}#oc-go-export-root.oc-mode-large #oc-go-export-drawer{width:min(780px,calc(100vw - 24px))}.oc-mode-large .oc-stat-grid{grid-template-columns:1fr 1fr}.oc-mode-large .oc-dim-grid{grid-template-columns:1fr}}` } function setDrawerOpen(open) { const root = document.getElementById("oc-go-export-root") const toggle = document.getElementById("oc-go-export-toggle") const backdrop = document.getElementById("oc-go-export-backdrop") if (!root || !toggle) return root.classList.toggle("oc-open", open) toggle.textContent = open ? "×" : "Go" toggle.title = open ? t("toggleClose") : t("toggleOpen") if (backdrop) backdrop.hidden = loadSettings().displayMode !== "large" || !open setPanelOpen(open) } async function setStatus(btn, text) { const info = $q("#oc-go-export-info") const isProgress = text && /…$/.test(text) if (info && text) info.textContent = text if (info && !isProgress) { const wsData = await getWorkspaceData().catch(() => emptyRec()) if (!text || text === t("msgReady") || text === t("msgRefreshed")) { const last = wsData.lastSync ? fmtDateTime(wsData.lastSync) : t("msgNone") info.textContent = t("msgInfoBar", wsData.detail.length.toLocaleString(), wsData.summary.length.toLocaleString(), last) } renderPanel(wsData.detail, wsData.summary, wsData.keyNames || {}) } } function inject() { if (document.getElementById("oc-go-export-root")) return pruneStale() loadSettings() injectStyles() const root = document.createElement("div") root.id = "oc-go-export-root" root.classList.add(loadSettings().displayMode === "large" ? "oc-mode-large" : "oc-mode-compact") const backdrop = document.createElement("div") backdrop.id = "oc-go-export-backdrop" backdrop.hidden = true const toggle = document.createElement("button") toggle.id = "oc-go-export-toggle" toggle.type = "button" toggle.textContent = "Go" toggle.title = t("toggleOpen") const drawer = document.createElement("div") drawer.id = "oc-go-export-drawer" const head = document.createElement("div") head.className = "oc-drawer-head" head.innerHTML = `${t("panelTitle")}` const headActions = document.createElement("div") headActions.className = "oc-head-actions" const btnExpand = document.createElement("button") btnExpand.id = "oc-go-export-expand" btnExpand.type = "button" btnExpand.className = "oc-head-btn" const _initMode = loadSettings().displayMode btnExpand.textContent = _initMode === "large" ? "⤡" : "⤢" btnExpand.title = _initMode === "large" ? t("btnExpandCompact") : t("btnExpandLarge") const btnSettings = document.createElement("button") btnSettings.id = "oc-go-export-settings-btn" btnSettings.type = "button" btnSettings.className = "oc-head-btn" btnSettings.textContent = "⚙" btnSettings.title = t("btnSettings") const btnClose = document.createElement("button") btnClose.className = "oc-head-btn oc-drawer-close" btnClose.type = "button" btnClose.textContent = "×" btnClose.title = t("btnClose") headActions.append(btnExpand, btnSettings, btnClose) head.appendChild(headActions) const settingsBlock = document.createElement("details") settingsBlock.id = "oc-go-export-settings" settingsBlock.className = "oc-settings-block oc-fold" settingsBlock.innerHTML = ` ${t("settingsTitle")}
${t("settingGroupDisplay")}
${t("settingDisplayMode")}
${t("settingLang")}
${t("settingClickOutside")}
${t("settingGroupSync")}
${t("settingAutoSync")} ${t("settingAutoSyncNote")}
${t("settingGroupExport")}
${t("settingExportPreset")}
${t("settingExportOpen")}
${t("settingGroupPanel")}
${t("settingOverviewOpen")}
${t("settingDimensionsOpen")}
${t("settingDateFormat")}
${t("settingGroupAdvanced")}
${t("settingPageGap")}
${t("settingTopModel")}
${t("settingTopKey")}
${t("securityNote")}
` const mkBtn = (label, primary) => { const b = document.createElement("button") b.type = "button" b.textContent = label if (primary) b.className = "oc-primary" return b } const actions = document.createElement("div") actions.className = "oc-actions" const btnFull = mkBtn(t("btnFull"), true) const btnInc = mkBtn(t("btnInc"), false) const btnRefresh = mkBtn(t("btnRefresh"), false) const btnNames = mkBtn(t("btnNames"), false) const btnClear = mkBtn(t("btnClear"), false) btnClear.classList.add("oc-danger", "oc-span2") const btnCancel = mkBtn(t("btnCancel"), false) btnCancel.disabled = true btnCancel.addEventListener("click", abortActiveRun) actions.append(btnFull, btnInc, btnRefresh, btnNames, btnCancel, btnClear) const info = document.createElement("div") info.id = "oc-go-export-info" const exportBlock = document.createElement("details") exportBlock.className = "oc-export-block oc-fold" exportBlock.open = loadSettings().exportSectionOpen exportBlock.innerHTML = ` ${t("exportTitle")}
~
` const body = document.createElement("div") body.id = "oc-go-export-body" const panel = document.createElement("div") panel.id = "oc-go-export-panel" body.appendChild(panel) const sidebar = document.createElement("div") sidebar.id = "oc-go-export-sidebar" sidebar.append(settingsBlock, actions, info, exportBlock) const bodyWrap = document.createElement("div") bodyWrap.id = "oc-go-export-body-wrap" bodyWrap.append(sidebar, body) // 一次性安全提示:仅首次打开显示,点击「我知道了」后不再出现(不打断日常使用) if (!localStorage.getItem(SEC_WARN_KEY)) { const sec = document.createElement("div") sec.className = "oc-security-note" const secTitle = document.createElement("div") secTitle.className = "oc-security-title" secTitle.textContent = t("securityWarnTitle") const secBody = document.createElement("div") secBody.textContent = t("securityWarnBody") const secRow = document.createElement("div") secRow.className = "oc-security-actions" const btnRepo = document.createElement("button") btnRepo.type = "button" btnRepo.textContent = t("securityWarnRepo") btnRepo.addEventListener("click", () => window.open(SEC_WARN_URL, "_blank", "noopener")) const btnOk = document.createElement("button") btnOk.type = "button" btnOk.className = "oc-primary" btnOk.textContent = t("securityWarnOk") btnOk.addEventListener("click", () => { localStorage.setItem(SEC_WARN_KEY, "1") sec.remove() }) secRow.append(btnRepo, btnOk) sec.append(secTitle, secBody, secRow) drawer.insertBefore(sec, drawer.firstChild) } drawer.append(head, bodyWrap) root.append(backdrop, drawer, toggle) document.body.appendChild(root) const openDrawer = () => setDrawerOpen(true) const closeDrawer = () => setDrawerOpen(false) toggle.addEventListener("click", () => setDrawerOpen(!root.classList.contains("oc-open"))) btnClose.addEventListener("click", closeDrawer) backdrop.addEventListener("click", () => { if (loadSettings().clickOutsideClose) closeDrawer() }) const onDocClick = (e) => { if (!root.classList.contains("oc-open")) return if (loadSettings().displayMode === "large") return if (!loadSettings().clickOutsideClose) return // 忽略游离节点(如下载用的临时 )触发的点击 if (!document.body.contains(e.target)) return if (root.contains(e.target)) return closeDrawer() } const onDocKeydown = (e) => { if (e.key === "Escape" && root.classList.contains("oc-open")) closeDrawer() } document.addEventListener("click", onDocClick) document.addEventListener("keydown", onDocKeydown) // 语言切换重建时清理监听器,避免泄漏 root.addEventListener("oc-destroy", () => { abortActiveRun() document.removeEventListener("click", onDocClick) document.removeEventListener("keydown", onDocKeydown) }) btnExpand.addEventListener("click", () => { const next = loadSettings().displayMode === "large" ? "compact" : "large" saveSettings({ displayMode: next }) applyDisplayMode(next) if (!root.classList.contains("oc-open")) setDrawerOpen(true) else setDrawerOpen(true) }) btnSettings.addEventListener("click", () => { settingsBlock.open = !settingsBlock.open }) const bindSetting = (sel, key, transform = (v) => v) => { const el = $q(sel) if (!el) return const handler = () => { let val = el.type === "checkbox" ? el.checked : el.value if (el.type === "number") val = Math.min(20, Math.max(3, parseInt(val, 10) || DEFAULT_SETTINGS[key])) if (key === "pageGapMs") val = parseInt(val, 10) if (key === "exportPresetDays") val = val === "all" ? "all" : parseInt(val, 10) saveSettings({ [key]: transform(val) }) if (key === "displayMode") applyDisplayMode(val) if (key === "exportPresetDays") setExportRange(val) if (key === "exportSectionOpen") exportBlock.open = val if (key === "overviewOpen" || key === "dimensionsOpen" || key === "topModelCount" || key === "topKeyCount" || key === "dateFormat") { getWorkspaceData().then((ws) => renderPanel(ws.detail, ws.summary, ws.keyNames || {})).catch(() => {}) } } el.addEventListener("change", handler) if (el.type === "number") el.addEventListener("input", handler) } $qa('input[name="oc-display-mode"]').forEach((el) => { el.addEventListener("change", () => { if (!el.checked) return saveSettings({ displayMode: el.value }) applyDisplayMode(el.value) }) }) $qa('input[name="oc-lang"]').forEach((el) => { el.addEventListener("change", () => { if (!el.checked) return // 保存面板开关状态,inject() 会重新读取 const wasOpen = root.classList.contains("oc-open") setPanelOpen(wasOpen) settingsCache = null saveSettings({ lang: el.value }) const oldRoot = document.getElementById("oc-go-export-root") if (oldRoot) { oldRoot.dispatchEvent(new CustomEvent("oc-destroy")) oldRoot.remove() } inject() }) }) $qa('input[name="oc-export-preset"]').forEach((el) => { el.addEventListener("change", () => { if (!el.checked) return const val = el.value === "all" ? "all" : parseInt(el.value, 10) saveSettings({ exportPresetDays: val }) setExportRange(val) }) }) bindSetting("#oc-set-click-outside", "clickOutsideClose") bindSetting("#oc-set-auto-sync", "autoSync") bindSetting("#oc-set-export-open", "exportSectionOpen") bindSetting("#oc-set-overview-open", "overviewOpen") bindSetting("#oc-set-dimensions-open", "dimensionsOpen") bindSetting("#oc-set-date-format", "dateFormat") bindSetting("#oc-set-page-gap", "pageGapMs", (v) => parseInt(v, 10)) bindSetting("#oc-set-top-model", "topModelCount") bindSetting("#oc-set-top-key", "topKeyCount") settingsBlock.querySelectorAll("input, select, label").forEach((el) => { el.addEventListener("click", (e) => e.stopPropagation()) }) const guard = (fn) => () => { openDrawer() root.classList.add("oc-busy") const buttons = [btnFull, btnInc, btnRefresh, btnNames, btnClear, btnCancel] buttons.forEach((b) => (b.disabled = true)) btnCancel.disabled = false // 抓取期间仅「停止」可点 Promise.race([ fn(), new Promise((_, rej) => setTimeout(() => { abortActiveRun() // 超时 = 中止抓取,而不是让它后台继续 rej(new Error(t("msgTimeout"))) }, 10 * 60 * 1000), ), ]) .catch((e) => { setStatus(null, t("msgError", e.message)) console.error(e) }) .finally(() => { root.classList.remove("oc-busy") buttons.forEach((b) => (b.disabled = false)) }) } btnFull.addEventListener("click", guard(() => run("full", btnFull))) btnInc.addEventListener("click", guard(() => run("incremental", btnInc))) btnRefresh.addEventListener("click", guard(async () => setStatus(null, t("msgRefreshed")))) let clearConfirmTimer = null btnClear.addEventListener("click", () => { if (clearConfirmTimer) { clearTimeout(clearConfirmTimer) clearConfirmTimer = null btnClear.textContent = t("btnClear") btnClear.classList.remove("oc-danger-confirm") guard(async () => { await idbDelete(WS_ID) setStatus(null, t("msgCleared")) syncSettingsUI() })() } else { btnClear.textContent = t("btnClearConfirm") btnClear.classList.add("oc-danger-confirm") clearConfirmTimer = setTimeout(() => { clearConfirmTimer = null btnClear.textContent = t("btnClear") btnClear.classList.remove("oc-danger-confirm") }, 3000) } }) btnNames.addEventListener("click", guard(() => refreshApiKeyNames())) exportBlock.querySelectorAll(".oc-export-presets button").forEach((b) => { b.addEventListener("click", (e) => { e.stopPropagation() setExportRange(b.dataset.range) }) }) exportBlock.querySelectorAll(".oc-export-actions button, .oc-export-dates input").forEach((el) => { el.addEventListener("click", (e) => e.stopPropagation()) }) $q("#oc-export-from")?.addEventListener("change", () => $qa(".oc-export-presets button").forEach((b) => b.classList.remove("oc-active"))) $q("#oc-export-to")?.addEventListener("change", () => $qa(".oc-export-presets button").forEach((b) => b.classList.remove("oc-active"))) $q("#oc-export-csv")?.addEventListener("click", () => doExport("csv")) $q("#oc-export-xlsx")?.addEventListener("click", () => doExport("xlsx")) applyDisplayMode() syncSettingsUI() setExportRange(loadSettings().exportPresetDays) setDrawerOpen(panelOpen()) setStatus(null, t("msgReady")) if (autoEnabled()) { waitFor(() => !!$q('[data-slot="usage-table"]'), 15000).then(async () => { const wsData = await getWorkspaceData() if (!wsData.lastSync || Date.now() - wsData.lastSync > AUTO_GAP_MS) { openDrawer() setStatus(null, t("msgAutoSync")) run("incremental", btnInc, { silent: true, downloadFiles: false }).catch(() => {}).finally(() => touch()) } else touch() }) } else touch() } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", inject) else inject() })()