OpenCode Go Usage Export

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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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
// @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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = "") =>
      `<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 ? 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 = `<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 class="oc-settings-row">
            <span>${t("settingDateFormat")}</span>
            <select id="oc-set-date-format">
              <option value="auto">${t("settingDateFormatAuto")}</option>
              <option value="iso">2026-08-12</option>
              <option value="ymd">2026/08/12</option>
              <option value="dmy">12/08/2026</option>
              <option value="mdy">08/12/2026</option>
            </select>
          </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" || 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()
})()