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.
Per
// ==UserScript==
// @name OpenCode Go Usage Export
// @namespace https://github.com/Shirolin/opencode-go-usage-export
// @version 1.0.3
// @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
// @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,
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: "自动",
// 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",
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: "自動",
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: "自動",
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
}
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)
const meta = $q("#oc-set-meta")
if (meta) {
getWorkspaceData()
.then((ws) => {
const last = ws.lastSync ? new Date(ws.lastSync).toLocaleString() : 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 = (t) => (t ? new Date(t).toLocaleDateString() : "-")
const windowText = winStart != null ? `${fmtD(winStart)} ~ ${fmtD(now)}(${winLabel})` : winLabel
// bar 统一以费用为主指标排序和宽度,secondary 为右侧标注文字
const barRow = (label, cost, maxCost, secondary = "") =>
`<div class="oc-bar-row">
<div class="oc-bar-meta">
<span class="oc-bar-label" title="${escHtml(label)}">${escHtml(label)}</span>
<span class="oc-bar-cost">${cost != null ? "$" + cost.toFixed(2) : ""}</span>
</div>
<div class="oc-bar-body">
<div class="oc-bar-track"><div class="oc-bar-fill" style="width:${maxCost ? Math.max(4, (cost / maxCost) * 100) : 0}%"></div></div>
<span class="oc-bar-val">${secondary}</span>
</div>
</div>`
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 `<div class="oc-quota-row">
<div class="oc-quota-meta"><span>${label}</span><span class="oc-quota-num">$${spent.toFixed(2)} / $${limit}</span></div>
<div class="oc-quota-track"><div class="oc-quota-fill ${cls}" style="width:${Math.max(2, pct)}%"></div></div>
</div>`
}
const dimFolds = []
const dimOpen = settings.dimensionsOpen ? " open" : ""
// 三个维度统一按费用降序,bar 宽度 = 费用占比
if (byModel.length) {
const maxCost = byModel[0].costUSD
dimFolds.push(`<details class="oc-fold oc-dim-fold"${dimOpen}>
<summary>${t("dimByModel", byModel.length)}</summary>
<div class="oc-fold-body">${byModel.map((m) => barRow(m.key, m.costUSD, maxCost, fmtT(m.inputTokens + m.cacheReadTokens + m.outputTokens) + t("unitTok"))).join("")}</div>
</details>`)
}
if (byKey.length) {
const maxCost = byKey[0].costUSD
dimFolds.push(`<details class="oc-fold oc-dim-fold"${dimOpen}>
<summary>${t("dimByKey", byKey.length)}</summary>
<div class="oc-fold-body">${byKey.map((m) => barRow(m.key, m.costUSD, maxCost, m.requests.toLocaleString() + t("unitReqs"))).join("")}</div>
</details>`)
}
if (byPlan.length) {
const planSorted = [...byPlan].sort((a, b) => b.costUSD - a.costUSD)
const maxCost = planSorted[0].costUSD
dimFolds.push(`<details class="oc-fold oc-dim-fold"${dimOpen}>
<summary>${t("dimByPlan", planSorted.length)}</summary>
<div class="oc-fold-body">${planSorted.map((m) => barRow(m.key, m.costUSD, maxCost, fmtT(m.inputTokens + m.cacheReadTokens + m.outputTokens) + t("unitTok") + " · " + m.requests.toLocaleString() + t("unitReqs"))).join("")}</div>
</details>`)
}
const overviewOpen = settings.overviewOpen ? " open" : ""
const presetStr = String(preset)
panel.innerHTML = `
<details class="oc-fold"${overviewOpen}>
<summary>${t("statOverview")}</summary>
<div class="oc-fold-body">
<div class="oc-stat-presets">
<button type="button" data-stat-range="7"${presetStr === "7" ? ' class="oc-active"' : ""}>${t("exportPreset7")}</button>
<button type="button" data-stat-range="30"${presetStr === "30" ? ' class="oc-active"' : ""}>${t("exportPreset30")}</button>
<button type="button" data-stat-range="all"${presetStr === "all" ? ' class="oc-active"' : ""}>${t("exportPresetAll")}</button>
</div>
<div class="oc-period">
<div class="oc-period-line"><span class="oc-period-k">${t("statWindowLabel")}</span>${windowText}</div>
<div class="oc-period-line"><span class="oc-period-k">${t("statRangeLabel")}</span>${minTC ? `${fmtD(minTC)} ~ ${fmtD(maxTC)}` : t("statEmptyDetail")}${viewSummary.length ? ` · ${t("statSummaryGroups", viewSummary.length)}` : ""}</div>
</div>
<div class="oc-panel-head">
<span class="oc-stat-total">${t("statTotalRequests", total.requests.toLocaleString())}</span>
<span class="oc-stat-cost-pill">$${viewCost.toFixed(2)}</span>
</div>
<div class="oc-stat-grid">
<div class="oc-stat"><div class="oc-stat-k">Input</div><div class="oc-stat-v">${fmtT(total.inputTokens)}</div></div>
<div class="oc-stat"><div class="oc-stat-k">Cache</div><div class="oc-stat-v">${fmtT(total.cacheReadTokens)}</div></div>
<div class="oc-stat"><div class="oc-stat-k">Output</div><div class="oc-stat-v">${fmtT(total.outputTokens)}</div></div>
<div class="oc-stat"><div class="oc-stat-k">5h / 7d</div><div class="oc-stat-v oc-stat-v-sm">$${cost5h.toFixed(2)} / $${cost7d.toFixed(2)}</div></div>
</div>
<div class="oc-quota-block">
<div class="oc-section-label">${t("statQuotaTitle")}</div>
${quotaRow(t("statQuota5h"), cost5h, 12)}
${quotaRow(t("statQuota7d"), cost7d, 30)}
${quotaRow(t("statQuota30d"), cost30dQuota, 60)}
</div>
<div class="oc-panel-foot">
<span>${t("statFootDetail", viewDetail.length.toLocaleString(), viewSummary.length.toLocaleString())}</span>
<span>${t("statFootKeys", Object.keys(keyNames || {}).length)}</span>
</div>
</div>
</details>
<div class="oc-dim-grid">${dimFolds.join("") || `<div class="oc-muted oc-empty">${t("statEmptyDim")}</div>`}</div>`
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 ? new Date(wsData.lastSync).toLocaleString() : 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 = `<b>${t("panelTitle")}</b>`
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 = `
<summary>${t("settingsTitle")}</summary>
<div class="oc-settings-inner">
<div class="oc-settings-group">
<div class="oc-settings-label">${t("settingGroupDisplay")}</div>
<div class="oc-settings-row">
<span>${t("settingDisplayMode")}</span>
<div class="oc-settings-radios">
<label><input type="radio" name="oc-display-mode" value="compact" />${t("settingDisplayCompact")}</label>
<label><input type="radio" name="oc-display-mode" value="large" />${t("settingDisplayLarge")}</label>
</div>
</div>
<div class="oc-settings-row">
<span>${t("settingLang")}</span>
<div class="oc-settings-radios">
<label><input type="radio" name="oc-lang" value="" />${t("settingLangAuto")}</label>
<label><input type="radio" name="oc-lang" value="zh" />简体</label>
<label><input type="radio" name="oc-lang" value="zh-tw" />繁體</label>
<label><input type="radio" name="oc-lang" value="en" />English</label>
<label><input type="radio" name="oc-lang" value="ja" />日本語</label>
</div>
</div>
<div class="oc-settings-row">
<span>${t("settingClickOutside")}</span>
<label class="oc-toggle"><input type="checkbox" id="oc-set-click-outside" /><span class="oc-toggle-track"></span></label>
</div>
</div>
<div class="oc-settings-group">
<div class="oc-settings-label">${t("settingGroupSync")}</div>
<div class="oc-settings-row">
<span>${t("settingAutoSync")} <small style="color:#666">${t("settingAutoSyncNote")}</small></span>
<label class="oc-toggle"><input type="checkbox" id="oc-set-auto-sync" /><span class="oc-toggle-track"></span></label>
</div>
</div>
<div class="oc-settings-group">
<div class="oc-settings-label">${t("settingGroupExport")}</div>
<div class="oc-settings-row">
<span>${t("settingExportPreset")}</span>
<div class="oc-settings-radios">
<label><input type="radio" name="oc-export-preset" value="7" />${t("exportPreset7")}</label>
<label><input type="radio" name="oc-export-preset" value="30" />${t("exportPreset30")}</label>
<label><input type="radio" name="oc-export-preset" value="all" />${t("exportPresetAll")}</label>
</div>
</div>
<div class="oc-settings-row">
<span>${t("settingExportOpen")}</span>
<label class="oc-toggle"><input type="checkbox" id="oc-set-export-open" /><span class="oc-toggle-track"></span></label>
</div>
</div>
<div class="oc-settings-group">
<div class="oc-settings-label">${t("settingGroupPanel")}</div>
<div class="oc-settings-row">
<span>${t("settingOverviewOpen")}</span>
<label class="oc-toggle"><input type="checkbox" id="oc-set-overview-open" /><span class="oc-toggle-track"></span></label>
</div>
<div class="oc-settings-row">
<span>${t("settingDimensionsOpen")}</span>
<label class="oc-toggle"><input type="checkbox" id="oc-set-dimensions-open" /><span class="oc-toggle-track"></span></label>
</div>
</div>
<details class="oc-settings-advanced">
<summary>${t("settingGroupAdvanced")}</summary>
<div class="oc-settings-row" style="margin-top:6px">
<span>${t("settingPageGap")}</span>
<select id="oc-set-page-gap"><option value="250">250 ms</option><option value="350">350 ms</option><option value="500">500 ms</option></select>
</div>
<div class="oc-settings-row">
<span>${t("settingTopModel")}</span>
<input type="number" id="oc-set-top-model" min="3" max="20" />
</div>
<div class="oc-settings-row">
<span>${t("settingTopKey")}</span>
<input type="number" id="oc-set-top-key" min="3" max="20" />
</div>
</details>
<div class="oc-settings-meta" id="oc-set-meta"></div>
<div class="oc-settings-security">${t("securityNote")}</div>
</div>`
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 = `
<summary>${t("exportTitle")}</summary>
<div class="oc-export-inner">
<div class="oc-export-presets">
<button type="button" data-range="7">${t("exportPreset7")}</button>
<button type="button" data-range="30" class="oc-active">${t("exportPreset30")}</button>
<button type="button" data-range="all">${t("exportPresetAll")}</button>
</div>
<div class="oc-export-dates">
<input type="date" id="oc-export-from" />
<span>~</span>
<input type="date" id="oc-export-to" />
</div>
<div class="oc-export-actions">
<button type="button" id="oc-export-csv" class="oc-primary">${t("exportCsv")}</button>
<button type="button" id="oc-export-xlsx">${t("exportExcel")}</button>
</div>
</div>`
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
// 忽略游离节点(如下载用的临时 <a>)触发的点击
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") {
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-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()
})()