Codex Quota Compass UI

在 ChatGPT 页面直接显示 Codex 额度、重置时间、每日用量和客户端汇总。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Codex Quota Compass UI
// @name:zh-CN   Codex 用量指南针 UI
// @namespace    https://chatgpt.com/codex
// @version      0.1.9
// @description  在 ChatGPT 页面直接显示 Codex 额度、重置时间、每日用量和客户端汇总。
// @description:zh-CN 在 ChatGPT 页面直接显示 Codex 额度、重置时间、每日用量和客户端汇总。
// @author       creamtea47
// @license      MIT
// @match        https://chatgpt.com/*
// @run-at       document-idle
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  'use strict';

  const CONFIG = {
    DATE_BUCKET_MODE: 'utc',
    USD_PER_CREDIT: 40 / 1000,
    ROLLING_DAYS: 30,
    USAGE_PATH: '/backend-api/wham/usage',
    DAILY_USAGE_PATH: '/backend-api/wham/analytics/daily-workspace-usage-counts',
  };

  const ROOT_ID = 'codex-quota-compass-ui';
  const SCRIPT_VERSION = '0.1.9';
  const STORAGE_KEY = 'codex-quota-compass-ui:collapsed';
  const FULLSCREEN_STORAGE_KEY = 'codex-quota-compass-ui:fullscreen';
  const POSITION_STORAGE_KEY = 'codex-quota-compass-ui:position';
  const TOKEN_UNIT_STORAGE_KEY = 'codex-quota-compass-ui:token-unit';
  const DAY_MS = 24 * 60 * 60 * 1000;
  const HOUR_SECONDS = 60 * 60;
  const WEEK_SECONDS = 7 * 24 * HOUR_SECONDS;

  const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
  const round = (v, d = 2) => Number(Number(v).toFixed(d));
  const pad2 = (x) => String(x).padStart(2, '0');
  const last = (arr) => (arr.length ? arr[arr.length - 1] : undefined);
  const escapeHtml = (s) =>
    String(s ?? '').replace(/[&<>"']/g, (ch) => ({
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#039;',
    })[ch]);

  const fmtInt = (v) => new Intl.NumberFormat().format(n(v));
  const fmtNum = (v, digits = 2) =>
    new Intl.NumberFormat(undefined, {
      maximumFractionDigits: digits,
      minimumFractionDigits: 0,
    }).format(n(v));
  const getTokenUnit = () => {
    const value = localStorage.getItem(TOKEN_UNIT_STORAGE_KEY);
    return value === 'm' || value === 'yi' ? value : 'raw';
  };
  const fmtTokensCompact = (v) => {
    const unit = getTokenUnit();
    if (unit === 'm') return `${fmtNum(n(v) / 1_000_000, 2)}M`;
    if (unit === 'yi') return `${fmtNum(n(v) / 100_000_000, 2)}亿`;
    return fmtInt(v);
  };
  const fmtTokensLong = fmtTokensCompact;
  const fmtTokenWan = (v) => `≈ ${fmtNum(n(v) / 10_000, 2)} 万 tokens`;
  const fmtTokenYi = (v) => `≈ ${fmtNum(n(v) / 100_000_000, 2)} 亿 tokens`;
  const fmtTokenUnitHint = (v) => {
    const unit = getTokenUnit();
    if (unit === 'm') return fmtTokenWan(v);
    if (unit === 'yi') return fmtTokenYi(v);
    return '';
  };
  const tokenColumnLabel = (label) =>
    getTokenUnit() === 'm'
      ? `${label} (M)`
      : getTokenUnit() === 'yi'
        ? `${label} (亿)`
        : label;
  const fmtPct = (v) => `${fmtNum(n(v), 2)}%`;
  const fmtLocal = (ms) => new Date(ms).toLocaleString();
  const fmtUTC = (ms) =>
    new Date(ms).toISOString().replace('T', ' ').replace('.000Z', ' UTC');

  function parseRgb(color) {
    const match = String(color || '').match(/rgba?\(([^)]+)\)/i);
    if (!match) return null;
    const [r, g, b] = match[1].split(',').slice(0, 3).map((v) => Number.parseFloat(v));
    if (![r, g, b].every(Number.isFinite)) return null;
    return { r, g, b };
  }

  function luminance({ r, g, b }) {
    const channel = (v) => {
      const x = v / 255;
      return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
    };
    return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
  }

  function detectPageTheme() {
    const bodyBg = getComputedStyle(document.body).backgroundColor;
    const rootBg = getComputedStyle(document.documentElement).backgroundColor;
    const rgb = parseRgb(bodyBg) || parseRgb(rootBg);
    if (rgb) return luminance(rgb) < 0.45 ? 'dark' : 'light';
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }

  function syncTheme(root) {
    root.dataset.cqTheme = detectPageTheme();
  }

  function clamp(value, min, max) {
    if (!Number.isFinite(value)) return min;
    return Math.min(Math.max(value, min), Math.max(min, max));
  }

  function readSavedPosition() {
    try {
      const pos = JSON.parse(localStorage.getItem(POSITION_STORAGE_KEY) || 'null');
      if (!pos || !Number.isFinite(pos.left) || !Number.isFinite(pos.top)) return null;
      return pos;
    } catch {
      return null;
    }
  }

  function applySavedPosition(root) {
    const pos = readSavedPosition();
    if (!pos) return;
    const maxLeft = window.innerWidth - 80;
    const maxTop = window.innerHeight - 44;
    root.style.left = `${clamp(pos.left, 8, maxLeft)}px`;
    root.style.top = `${clamp(pos.top, 8, maxTop)}px`;
    root.style.right = 'auto';
    root.style.bottom = 'auto';
  }

  function saveRootPosition(root) {
    const rect = root.getBoundingClientRect();
    localStorage.setItem(
      POSITION_STORAGE_KEY,
      JSON.stringify({
        left: Math.round(rect.left),
        top: Math.round(rect.top),
      })
    );
  }

  const ymdUTC = (x) => {
    const d = new Date(x);
    return [
      d.getUTCFullYear(),
      pad2(d.getUTCMonth() + 1),
      pad2(d.getUTCDate()),
    ].join('-');
  };

  const ymdLocal = (x) => {
    const d = new Date(x);
    d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
    return d.toISOString().slice(0, 10);
  };

  const ymdForApi = (ms) =>
    CONFIG.DATE_BUCKET_MODE === 'utc' ? ymdUTC(ms) : ymdLocal(ms);

  const addDaysLocalMs = (ms, days) => {
    const d = new Date(ms);
    d.setDate(d.getDate() + days);
    return d.getTime();
  };

  const addDaysForApi = (ms, days) =>
    CONFIG.DATE_BUCKET_MODE === 'utc'
      ? ms + days * DAY_MS
      : addDaysLocalMs(ms, days);

  const firstDayOfMonthUTC = (ms) => {
    const d = new Date(ms);
    return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-01`;
  };

  const firstDayOfMonthLocal = (ms) => {
    const d = new Date(ms);
    return ymdLocal(new Date(d.getFullYear(), d.getMonth(), 1).getTime());
  };

  const firstDayOfMonthForApi = (ms) =>
    CONFIG.DATE_BUCKET_MODE === 'utc'
      ? firstDayOfMonthUTC(ms)
      : firstDayOfMonthLocal(ms);

  const utcOffsetLabel = (ms) => {
    const offsetMinutes = -new Date(ms).getTimezoneOffset();
    const sign = offsetMinutes >= 0 ? '+' : '-';
    const abs = Math.abs(offsetMinutes);
    return `UTC${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
  };

  const tokenTotal = (obj = {}) =>
    n(obj.text_total_tokens) ||
    n(obj.cached_text_input_tokens) +
      n(obj.uncached_text_input_tokens) +
      n(obj.text_output_tokens);

  const looksLikeJwt = (s) =>
    typeof s === 'string' && s.length > 100 && s.split('.').length >= 3;

  function findAccessToken(obj, depth = 0) {
    if (!obj || typeof obj !== 'object' || depth > 8) return '';
    for (const [key, value] of Object.entries(obj)) {
      if (
        typeof value === 'string' &&
        /access/i.test(key) &&
        looksLikeJwt(value)
      ) {
        return value;
      }
      if (value && typeof value === 'object') {
        const found = findAccessToken(value, depth + 1);
        if (found) return found;
      }
    }
    return '';
  }

  async function getAccessToken() {
    try {
      const res = await fetch('/api/auth/session', {
        credentials: 'include',
        headers: { accept: 'application/json' },
      });
      if (!res.ok) return '';
      return findAccessToken(await res.json());
    } catch {
      return '';
    }
  }

  async function apiGet(path, headers) {
    const res = await fetch(path, {
      method: 'GET',
      credentials: 'include',
      headers,
    });

    if (!res.ok) {
      const text = await res.text().catch(() => '');
      if (res.status === 401) {
        throw new Error('HTTP 401:没有拿到有效授权。请确认当前 Chrome 已登录 chatgpt.com,然后刷新页面重试。');
      }
      throw new Error(`HTTP ${res.status} ${res.statusText}: ${path}\n${text.slice(0, 500)}`);
    }

    return res.json();
  }

  function parseWindow(label, w, metadata = {}) {
    const usedPercent = n(w?.used_percent);
    const windowSeconds = n(w?.limit_window_seconds);
    const resetAfterSeconds = n(w?.reset_after_seconds);
    const resetAtSeconds = n(w?.reset_at);
    const resetAtMs = resetAtSeconds * 1000;
    const windowStartMs = resetAtMs - windowSeconds * 1000;
    const serverNowMs = resetAtMs - resetAfterSeconds * 1000;

    return {
      name: label,
      ...metadata,
      usedPercent,
      usedRatio: usedPercent / 100,
      windowSeconds,
      windowDays: round(windowSeconds / 86400, 4),
      windowStartMs,
      resetAtMs,
      serverNowMs,
      resetAfterSeconds,
      windowStartUTC: fmtUTC(windowStartMs),
      windowStartLocal: fmtLocal(windowStartMs),
      resetAtUTC: fmtUTC(resetAtMs),
      resetAtLocal: fmtLocal(resetAtMs),
      serverNowUTC: fmtUTC(serverNowMs),
      serverNowLocal: fmtLocal(serverNowMs),
      resetAfterHours: round(resetAfterSeconds / 3600, 2),
    };
  }

  function windowDurationLabel(windowData) {
    const seconds = n(windowData?.limit_window_seconds);
    if (seconds <= 0) return '未知周期窗口';

    const hours = seconds / HOUR_SECONDS;
    if (hours >= 24) {
      const days = hours / 24;
      return `${Number.isInteger(days) ? days : round(days, 2)}天窗口`;
    }
    return `${Number.isInteger(hours) ? hours : round(hours, 2)}小时窗口`;
  }

  function collectRateLimitWindows(name, rateLimit, sourcePrefix, scope) {
    const windows = [];
    for (const field of ['primary_window', 'secondary_window']) {
      const windowData = rateLimit?.[field];
      if (!windowData) continue;
      windows.push(parseWindow(
        `${name} - ${windowDurationLabel(windowData)}`,
        windowData,
        {
          scope,
          sourcePath: `${sourcePrefix}.${field}`,
        }
      ));
    }
    return windows;
  }

  function collectWindows(usage) {
    const windows = collectRateLimitWindows(
      '主限制',
      usage?.rate_limit,
      'usage.rate_limit',
      'main'
    );
    for (const [index, item] of (usage?.additional_rate_limits ?? []).entries()) {
      const name = item.limit_name || item.metered_feature || '额外限制';
      windows.push(...collectRateLimitWindows(
        name,
        item?.rate_limit,
        `usage.additional_rate_limits[${index}].rate_limit`,
        'additional'
      ));
    }
    return windows;
  }

  function findWindowByDuration(windows, targetSeconds) {
    // 字段名可能随接口调整,按窗口时长识别比固定 primary/secondary 更稳定。
    const toleranceSeconds = Math.max(HOUR_SECONDS, targetSeconds * 0.05);
    return windows.find((windowRow) =>
      Math.abs(n(windowRow.windowSeconds) - targetSeconds) <= toleranceSeconds
    );
  }

  function parseDailyRows(json) {
    return (json.data ?? [])
      .slice()
      .sort((a, b) => String(a.date).localeCompare(String(b.date)))
      .map((d) => {
        const t = d.totals ?? {};
        const credits = n(t.credits);
        return {
          date: d.date,
          credits: round(credits, 6),
          usd: round(credits * CONFIG.USD_PER_CREDIT, 2),
          users: n(t.users),
          threads: n(t.threads),
          turns: n(t.turns),
          tokens: tokenTotal(t),
          cachedInputTokens: n(t.cached_text_input_tokens),
          uncachedInputTokens: n(t.uncached_text_input_tokens),
          outputTokens: n(t.text_output_tokens),
          clientCount: Array.isArray(d.clients) ? d.clients.length : 0,
          clientCredits: (d.clients ?? [])
            .map((c) => `${c.client_id ?? 'UNKNOWN'}:${round(n(c.credits), 2)}`)
            .join(' | '),
        };
      });
  }

  function summarizeClients(json) {
    const map = new Map();
    for (const day of json.data ?? []) {
      for (const c of day.clients ?? []) {
        const id = c.client_id ?? 'UNKNOWN';
        const row = map.get(id) ?? {
          client: id,
          credits: 0,
          usd: 0,
          threads: 0,
          turns: 0,
          tokens: 0,
          cachedInputTokens: 0,
          uncachedInputTokens: 0,
          outputTokens: 0,
        };
        const credits = n(c.credits);
        row.credits += credits;
        row.usd += credits * CONFIG.USD_PER_CREDIT;
        row.threads += n(c.threads);
        row.turns += n(c.turns);
        row.tokens += tokenTotal(c);
        row.cachedInputTokens += n(c.cached_text_input_tokens);
        row.uncachedInputTokens += n(c.uncached_text_input_tokens);
        row.outputTokens += n(c.text_output_tokens);
        map.set(id, row);
      }
    }
    return [...map.values()]
      .map((r) => ({
        ...r,
        credits: round(r.credits, 6),
        usd: round(r.usd, 2),
      }))
      .sort((a, b) => b.tokens - a.tokens || b.credits - a.credits);
  }

  async function fetchDailyUsage(startDate, endExclusiveDate, headers) {
    const qs = new URLSearchParams({
      start_date: startDate,
      end_date: endExclusiveDate,
      group_by: 'day',
    });
    const url = `${CONFIG.DAILY_USAGE_PATH}?${qs}`;
    const json = await apiGet(url, headers);
    return {
      url: location.origin + url,
      rows: parseDailyRows(json),
      clients: summarizeClients(json),
    };
  }

  function summarizeRows(rangeName, rows, startDate, endExclusiveDate) {
    const credits = rows.reduce((s, r) => s + n(r.credits), 0);
    return {
      range: rangeName,
      bucketMode: CONFIG.DATE_BUCKET_MODE === 'utc' ? 'UTC日期桶' : '本地日期桶',
      startDate,
      endExclusiveDate,
      dateBuckets: rows.length,
      firstDate: rows[0]?.date ?? '',
      lastDate: last(rows)?.date ?? '',
      credits: round(credits, 6),
      usd: round(credits * CONFIG.USD_PER_CREDIT, 2),
      tokens: rows.reduce((s, r) => s + n(r.tokens), 0),
      threads: rows.reduce((s, r) => s + n(r.threads), 0),
      turns: rows.reduce((s, r) => s + n(r.turns), 0),
    };
  }

  function buildWeeklyEstimate({
    mainWeekly,
    sinceResetRows,
    sinceResetSummary,
    sinceResetStartDate,
  }) {
    const usedPercent = n(mainWeekly.usedPercent);
    const usedRatio = usedPercent / 100;
    const includedCredits = n(sinceResetSummary.credits);
    const resetDayRow = sinceResetRows.find((r) => r.date === sinceResetStartDate);
    const resetDayCredits = n(resetDayRow?.credits);
    const excludedCredits = Math.max(0, includedCredits - resetDayCredits);

    if (usedRatio <= 0) {
      return {
        basis: `${mainWeekly.name} ${mainWeekly.sourcePath}`,
        usedPercent,
        note: '已用比例为 0,无法反推总额度。',
      };
    }

    const totalWithResetDay = includedCredits / usedRatio;
    const totalWithoutResetDay = excludedCredits / usedRatio;
    const remainingWithResetDay = Math.max(0, totalWithResetDay - includedCredits);
    const remainingWithoutResetDay = Math.max(0, totalWithoutResetDay - excludedCredits);

    return {
      basis: `${mainWeekly.name} ${mainWeekly.sourcePath}`,
      usedPercent,
      usedRatio: round(usedRatio, 4),
      remainingRatio: round(1 - usedRatio, 4),
      bucketMode: CONFIG.DATE_BUCKET_MODE === 'utc' ? 'UTC日期桶' : '本地日期桶',
      includedCredits: round(includedCredits, 6),
      includedUsd: round(includedCredits * CONFIG.USD_PER_CREDIT, 2),
      resetDayCredits: round(resetDayCredits, 6),
      resetDayUsd: round(resetDayCredits * CONFIG.USD_PER_CREDIT, 2),
      excludedCredits: round(excludedCredits, 6),
      excludedUsd: round(excludedCredits * CONFIG.USD_PER_CREDIT, 2),
      totalCreditsWithResetDay: round(totalWithResetDay, 2),
      totalUsdWithResetDay: round(totalWithResetDay * CONFIG.USD_PER_CREDIT, 2),
      totalCreditsWithoutResetDay: round(totalWithoutResetDay, 2),
      totalUsdWithoutResetDay: round(totalWithoutResetDay * CONFIG.USD_PER_CREDIT, 2),
      remainingCreditsWithResetDay: round(remainingWithResetDay, 2),
      remainingUsdWithResetDay: round(remainingWithResetDay * CONFIG.USD_PER_CREDIT, 2),
      remainingCreditsWithoutResetDay: round(remainingWithoutResetDay, 2),
      remainingUsdWithoutResetDay: round(remainingWithoutResetDay * CONFIG.USD_PER_CREDIT, 2),
      note: 'daily analytics 只能按天聚合,实际值通常介于“排除重置日”和“包含重置日”之间。',
    };
  }

  async function loadCompassData() {
    const accessToken = await getAccessToken();
    const headers = { accept: 'application/json' };
    if (accessToken) headers.authorization = `Bearer ${accessToken}`;

    const usage = await apiGet(CONFIG.USAGE_PATH, headers);
    const windows = collectWindows(usage);
    const mainWindows = windows.filter((windowRow) => windowRow.scope === 'main');
    const mainWeekly = findWindowByDuration(mainWindows, WEEK_SECONDS);
    const mainFiveHour = findWindowByDuration(mainWindows, 5 * HOUR_SECONDS);

    if (!mainWeekly) {
      throw new Error('没有找到主 7 天额度窗口,无法计算上次重置时间和周额度。');
    }

    const apiNowMs = mainWeekly.serverNowMs || Date.now();
    const apiTodayDate = ymdForApi(apiNowMs);
    const endExclusiveDate = ymdForApi(addDaysForApi(apiNowMs, 1));
    const sinceResetStartDate = ymdForApi(mainWeekly.windowStartMs);
    const monthStartDate = firstDayOfMonthForApi(apiNowMs);
    const rollingStartDate = ymdForApi(
      addDaysForApi(apiNowMs, -(CONFIG.ROLLING_DAYS - 1))
    );

    const sinceReset = await fetchDailyUsage(
      sinceResetStartDate,
      endExclusiveDate,
      headers
    );
    const sinceResetSummary = summarizeRows(
      `上次重置至今 ${sinceResetStartDate} ~ ${apiTodayDate}`,
      sinceReset.rows,
      sinceResetStartDate,
      endExclusiveDate
    );

    const weeklyEstimate = buildWeeklyEstimate({
      mainWeekly,
      sinceResetRows: sinceReset.rows,
      sinceResetSummary,
      sinceResetStartDate,
    });

    const monthToDate = await fetchDailyUsage(monthStartDate, endExclusiveDate, headers);
    const monthToDateSummary = summarizeRows(
      `本月初至今 ${monthStartDate} ~ ${apiTodayDate}`,
      monthToDate.rows,
      monthStartDate,
      endExclusiveDate
    );

    const rolling = await fetchDailyUsage(rollingStartDate, endExclusiveDate, headers);
    const rollingSummary = summarizeRows(
      `近${CONFIG.ROLLING_DAYS}天 ${rollingStartDate} ~ ${apiTodayDate}`,
      rolling.rows,
      rollingStartDate,
      endExclusiveDate
    );

    return {
      fetchedAt: Date.now(),
      browserTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || '未知',
      browserUtcOffset: utcOffsetLabel(apiNowMs),
      apiNowMs,
      apiTodayDate,
      endExclusiveDate,
      sinceResetStartDate,
      monthStartDate,
      rollingStartDate,
      windows,
      mainWeekly,
      mainFiveHour,
      weeklyEstimate,
      ranges: {
        sinceReset: {
          label: '上次重置至今',
          summary: sinceResetSummary,
          rows: sinceReset.rows,
          clients: sinceReset.clients,
          url: sinceReset.url,
        },
        monthToDate: {
          label: '本月初至今',
          summary: monthToDateSummary,
          rows: monthToDate.rows,
          clients: monthToDate.clients,
          url: monthToDate.url,
        },
        rolling: {
          label: `近${CONFIG.ROLLING_DAYS}天`,
          summary: rollingSummary,
          rows: rolling.rows,
          clients: rolling.clients,
          url: rolling.url,
        },
      },
    };
  }

  function addStyles() {
    const css = `
      #${ROOT_ID} {
        --cq-bg: #ffffff;
        --cq-header: #ffffff;
        --cq-panel: #f9f9f9;
        --cq-panel-2: #f4f4f4;
        --cq-text: #0d0d0d;
        --cq-muted: #676767;
        --cq-line: rgba(0,0,0,.1);
        --cq-shadow: rgba(0,0,0,.12);
        --cq-accent: #10a37f;
        --cq-good: #10a37f;
        --cq-warn: #d97706;
        --cq-bad: #dc2626;
        --cq-blue: #2563eb;
        --cq-cyan: #0891b2;
        position: fixed;
        top: 76px;
        right: 18px;
        z-index: 2147483000;
        width: min(460px, calc(100vw - 32px));
        max-height: calc(100vh - 96px);
        color: var(--cq-text);
        font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        letter-spacing: 0;
        color-scheme: light;
      }
      #${ROOT_ID}[data-cq-theme="dark"] {
        --cq-bg: #212121;
        --cq-header: #212121;
        --cq-panel: #2f2f2f;
        --cq-panel-2: #373737;
        --cq-text: #ececec;
        --cq-muted: #b4b4b4;
        --cq-line: rgba(255,255,255,.14);
        --cq-shadow: rgba(0,0,0,.32);
        --cq-accent: #10a37f;
        --cq-good: #10a37f;
        --cq-warn: #f59e0b;
        --cq-bad: #f87171;
        --cq-blue: #60a5fa;
        --cq-cyan: #22d3ee;
        color-scheme: dark;
      }
      #${ROOT_ID} * { box-sizing: border-box; letter-spacing: 0; }
      #${ROOT_ID}.cq-collapsed { width: auto; }
      #${ROOT_ID}.cq-collapsed .cq-body { display: none; }
      #${ROOT_ID}.cq-collapsed .cq-shell {
        width: auto !important;
        height: auto !important;
        min-width: 0;
        min-height: 0;
        resize: none;
      }
      #${ROOT_ID}.cq-collapsed .cq-header {
        min-height: 40px;
        border-bottom: 0;
        padding: 7px 9px;
      }
      #${ROOT_ID}.cq-collapsed .cq-title span[data-cq-status],
      #${ROOT_ID}.cq-collapsed [data-cq-token-unit],
      #${ROOT_ID}.cq-collapsed [data-cq-refresh],
      #${ROOT_ID}.cq-collapsed [data-cq-fullscreen] {
        display: none;
      }
      #${ROOT_ID}.cq-collapsed .cq-title strong {
        max-width: 230px;
      }
      #${ROOT_ID}.cq-fullscreen {
        inset: 18px;
        width: auto !important;
        max-height: none;
        left: 18px !important;
        top: 18px !important;
        right: 18px !important;
        bottom: 18px !important;
      }
      #${ROOT_ID}.cq-fullscreen .cq-shell {
        height: calc(100vh - 36px);
        resize: none;
      }
      #${ROOT_ID}.cq-fullscreen .cq-body {
        max-height: calc(100vh - 92px);
      }
      #${ROOT_ID}.cq-fullscreen .cq-grid {
        grid-template-columns: repeat(4, minmax(0, 1fr));
      }
      #${ROOT_ID}.cq-fullscreen .cq-card.cq-wide {
        grid-column: span 2;
      }
      #${ROOT_ID}.cq-fullscreen .cq-table-wrap {
        max-height: min(52vh, 620px);
      }
      #${ROOT_ID} .cq-shell {
        overflow: hidden;
        resize: both;
        min-width: 360px;
        min-height: 220px;
        width: 100%;
        max-width: calc(100vw - 32px);
        max-height: calc(100vh - 96px);
        border: 1px solid var(--cq-line);
        border-radius: 8px;
        background: var(--cq-bg);
        box-shadow: 0 18px 50px var(--cq-shadow);
      }
      #${ROOT_ID} .cq-header {
        display: flex;
        align-items: center;
        gap: 10px;
        min-height: 48px;
        padding: 10px 12px;
        border-bottom: 1px solid var(--cq-line);
        background: var(--cq-header);
        cursor: move;
        user-select: none;
      }
      #${ROOT_ID}.cq-fullscreen .cq-header {
        cursor: default;
      }
      #${ROOT_ID} .cq-title {
        flex: 1;
        min-width: 0;
      }
      #${ROOT_ID} .cq-title strong {
        display: flex;
        align-items: center;
        gap: 6px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font-size: 14px;
      }
      #${ROOT_ID} .cq-version {
        flex: 0 0 auto;
        border: 1px solid var(--cq-line);
        border-radius: 999px;
        padding: 1px 6px;
        color: var(--cq-muted);
        background: var(--cq-panel-2);
        font-size: 10px;
        font-weight: 600;
      }
      #${ROOT_ID} .cq-title span {
        display: block;
        color: var(--cq-muted);
        font-size: 11px;
      }
      #${ROOT_ID} .cq-actions {
        display: flex;
        flex-wrap: wrap;
        justify-content: flex-end;
        gap: 6px;
      }
      #${ROOT_ID} button {
        border: 1px solid var(--cq-line);
        border-radius: 6px;
        background: var(--cq-panel-2);
        color: var(--cq-text);
        cursor: pointer;
        font: inherit;
      }
      #${ROOT_ID} button:hover { border-color: rgba(255,255,255,.28); }
      #${ROOT_ID} .cq-icon-btn {
        display: inline-grid;
        place-items: center;
        width: 30px;
        height: 30px;
        padding: 0;
      }
      #${ROOT_ID} .cq-unit-btn {
        min-width: 72px;
        height: 30px;
        padding: 0 8px;
        font-size: 11px;
      }
      #${ROOT_ID} .cq-unit-btn[data-cq-active="true"] {
        border-color: color-mix(in srgb, var(--cq-accent) 45%, var(--cq-line));
        background: color-mix(in srgb, var(--cq-accent) 12%, var(--cq-panel-2));
      }
      #${ROOT_ID} .cq-body {
        max-height: calc(100vh - 146px);
        overflow: auto;
        padding: 12px;
      }
      #${ROOT_ID} .cq-grid {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 8px;
      }
      #${ROOT_ID} .cq-card {
        border: 1px solid var(--cq-line);
        border-radius: 8px;
        background: var(--cq-panel);
        padding: 10px;
      }
      #${ROOT_ID} .cq-card.cq-wide { grid-column: 1 / -1; }
      #${ROOT_ID} .cq-label {
        color: var(--cq-muted);
        font-size: 11px;
        margin-bottom: 4px;
      }
      #${ROOT_ID} .cq-value {
        font-size: 20px;
        font-weight: 700;
      }
      #${ROOT_ID} .cq-sub {
        margin-top: 4px;
        color: var(--cq-muted);
        font-size: 11px;
      }
      #${ROOT_ID} .cq-meter {
        display: grid;
        grid-template-columns: 88px 1fr;
        gap: 12px;
        align-items: center;
      }
      #${ROOT_ID} .cq-ring {
        width: 88px;
        aspect-ratio: 1;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        place-items: center;
        border-radius: 50%;
        background:
          radial-gradient(circle at center, var(--cq-panel) 0 56%, transparent 57%),
          conic-gradient(var(--cq-ring-color) calc(var(--cq-pct) * 1%), rgba(255,255,255,.12) 0);
      }
      #${ROOT_ID} .cq-ring-label {
        color: var(--cq-muted);
        font-size: 11px;
        font-weight: 700;
        line-height: 1;
        margin-bottom: 3px;
      }
      #${ROOT_ID} .cq-ring span {
        font-weight: 800;
        font-size: 17px;
        line-height: 1.05;
      }
      #${ROOT_ID} .cq-stack { display: grid; gap: 7px; }
      #${ROOT_ID} .cq-kv {
        display: flex;
        justify-content: space-between;
        gap: 10px;
        border-bottom: 1px solid rgba(255,255,255,.06);
        padding-bottom: 6px;
      }
      #${ROOT_ID} .cq-kv span:first-child { color: var(--cq-muted); }
      #${ROOT_ID} .cq-kv span:last-child {
        text-align: right;
        overflow-wrap: anywhere;
      }
      #${ROOT_ID} .cq-tabs {
        display: flex;
        gap: 6px;
        margin: 12px 0 8px;
      }
      #${ROOT_ID} .cq-tab {
        flex: 1;
        min-width: 0;
        padding: 7px 8px;
        color: var(--cq-muted);
      }
      #${ROOT_ID} .cq-tab.cq-active {
        background: color-mix(in srgb, var(--cq-accent) 14%, var(--cq-panel-2));
        color: var(--cq-text);
        border-color: color-mix(in srgb, var(--cq-accent) 45%, var(--cq-line));
      }
      #${ROOT_ID} .cq-table-wrap {
        max-height: 260px;
        overflow: auto;
        border: 1px solid var(--cq-line);
        border-radius: 8px;
        background: var(--cq-panel);
      }
      #${ROOT_ID} table {
        width: 100%;
        border-collapse: collapse;
        min-width: 720px;
        font-size: 12px;
      }
      #${ROOT_ID} th,
      #${ROOT_ID} td {
        padding: 7px 8px;
        border-bottom: 1px solid rgba(255,255,255,.07);
        text-align: right;
        white-space: nowrap;
      }
      #${ROOT_ID} th:first-child,
      #${ROOT_ID} td:first-child { text-align: left; }
      #${ROOT_ID} th {
        position: sticky;
        top: 0;
        background: var(--cq-panel-2);
        color: var(--cq-text);
        z-index: 1;
      }
      #${ROOT_ID} .cq-section-head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        margin: 12px 0 7px;
      }
      #${ROOT_ID} .cq-section-head strong { font-size: 13px; }
      #${ROOT_ID} .cq-section-head button { padding: 5px 8px; font-size: 12px; }
      #${ROOT_ID} .cq-error {
        border: 1px solid rgba(251,113,133,.42);
        border-radius: 8px;
        background: rgba(251,113,133,.13);
        color: #fecdd3;
        padding: 10px;
        white-space: pre-wrap;
      }
      #${ROOT_ID} .cq-loading {
        display: flex;
        align-items: center;
        gap: 10px;
        color: var(--cq-muted);
      }
      #${ROOT_ID} .cq-spinner {
        width: 16px;
        height: 16px;
        border-radius: 50%;
        border: 2px solid rgba(255,255,255,.2);
        border-top-color: var(--cq-accent);
        animation: cq-spin .9s linear infinite;
      }
      @keyframes cq-spin { to { transform: rotate(360deg); } }
      @media (max-width: 720px) {
        #${ROOT_ID} {
          left: 12px;
          right: 12px;
          top: 64px;
          width: auto;
        }
        #${ROOT_ID}.cq-fullscreen {
          inset: 10px;
          left: 10px !important;
          top: 10px !important;
          right: 10px !important;
          bottom: 10px !important;
        }
        #${ROOT_ID}.cq-fullscreen .cq-shell { height: calc(100vh - 20px); }
        #${ROOT_ID} .cq-grid { grid-template-columns: 1fr; }
        #${ROOT_ID}.cq-fullscreen .cq-grid { grid-template-columns: 1fr; }
        #${ROOT_ID}.cq-fullscreen .cq-card.cq-wide { grid-column: 1 / -1; }
        #${ROOT_ID} .cq-meter { grid-template-columns: 76px 1fr; }
        #${ROOT_ID} .cq-ring { width: 76px; }
      }
    `;

    if (typeof GM_addStyle === 'function') {
      GM_addStyle(css);
      return;
    }

    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  }

  function getRoot() {
    let root = document.getElementById(ROOT_ID);
    if (root) return root;
    root = document.createElement('div');
    root.id = ROOT_ID;
    syncTheme(root);
    if (localStorage.getItem(STORAGE_KEY) === '1') root.classList.add('cq-collapsed');
    if (localStorage.getItem(FULLSCREEN_STORAGE_KEY) === '1') root.classList.add('cq-fullscreen');
    root.innerHTML = `
      <div class="cq-shell">
        <div class="cq-header">
          <div class="cq-title">
            <strong>Codex Quota Compass <span class="cq-version">v${SCRIPT_VERSION}</span></strong>
            <span data-cq-status>准备加载</span>
          </div>
          <div class="cq-actions">
            <button class="cq-unit-btn" type="button" data-cq-token-unit title="切换 Token 显示单位">Token: 原始</button>
            <button class="cq-icon-btn" type="button" data-cq-refresh title="刷新">↻</button>
            <button class="cq-icon-btn" type="button" data-cq-fullscreen title="全屏/还原">⛶</button>
            <button class="cq-icon-btn" type="button" data-cq-collapse title="折叠/展开">▾</button>
          </div>
        </div>
        <div class="cq-body" data-cq-body></div>
      </div>
    `;
    document.documentElement.appendChild(root);
    if (!root.classList.contains('cq-fullscreen')) applySavedPosition(root);
    new MutationObserver(() => syncTheme(root)).observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['class', 'style', 'data-theme'],
    });
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => syncTheme(root));
    root.querySelector('[data-cq-token-unit]').addEventListener('click', () => {
      const unit = getTokenUnit();
      const next = unit === 'raw' ? 'm' : unit === 'm' ? 'yi' : 'raw';
      localStorage.setItem(TOKEN_UNIT_STORAGE_KEY, next);
      updateTokenUnitButton(root);
      if (root.__cqData) render(root, root.__cqData);
    });
    updateTokenUnitButton(root);
    root.querySelector('[data-cq-refresh]').addEventListener('click', () => refresh(root));
    root.querySelector('[data-cq-fullscreen]').addEventListener('click', () => {
      root.classList.toggle('cq-fullscreen');
      if (root.classList.contains('cq-fullscreen')) {
        root.classList.remove('cq-collapsed');
        localStorage.setItem(STORAGE_KEY, '0');
      } else {
        applySavedPosition(root);
      }
      localStorage.setItem(FULLSCREEN_STORAGE_KEY, root.classList.contains('cq-fullscreen') ? '1' : '0');
      updateFullscreenButton(root);
    });
    updateFullscreenButton(root);
    root.querySelector('[data-cq-collapse]').addEventListener('click', () => {
      root.classList.toggle('cq-collapsed');
      if (root.classList.contains('cq-collapsed')) {
        root.classList.remove('cq-fullscreen');
        localStorage.setItem(FULLSCREEN_STORAGE_KEY, '0');
        updateFullscreenButton(root);
      }
      localStorage.setItem(STORAGE_KEY, root.classList.contains('cq-collapsed') ? '1' : '0');
    });
    setupDrag(root);
    return root;
  }

  function setupDrag(root) {
    const header = root.querySelector('.cq-header');
    if (!header) return;

    let dragging = false;
    let startX = 0;
    let startY = 0;
    let startLeft = 0;
    let startTop = 0;

    const onMove = (event) => {
      if (!dragging) return;
      const rect = root.getBoundingClientRect();
      const nextLeft = clamp(startLeft + event.clientX - startX, 4, window.innerWidth - Math.min(80, rect.width));
      const nextTop = clamp(startTop + event.clientY - startY, 4, window.innerHeight - Math.min(44, rect.height));
      root.style.left = `${nextLeft}px`;
      root.style.top = `${nextTop}px`;
      root.style.right = 'auto';
      root.style.bottom = 'auto';
    };

    const onUp = () => {
      if (!dragging) return;
      dragging = false;
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      root.classList.remove('cq-dragging');
      saveRootPosition(root);
    };

    header.addEventListener('mousedown', (event) => {
      if (event.button !== 0) return;
      if (root.classList.contains('cq-fullscreen')) return;
      if (event.target.closest('button, input, select, textarea, a')) return;

      const rect = root.getBoundingClientRect();
      dragging = true;
      startX = event.clientX;
      startY = event.clientY;
      startLeft = rect.left;
      startTop = rect.top;
      root.style.left = `${rect.left}px`;
      root.style.top = `${rect.top}px`;
      root.style.right = 'auto';
      root.style.bottom = 'auto';
      root.classList.add('cq-dragging');
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
      event.preventDefault();
    });

    window.addEventListener('resize', () => {
      if (root.classList.contains('cq-fullscreen')) return;
      applySavedPosition(root);
      saveRootPosition(root);
    });
  }

  function updateTokenUnitButton(root) {
    const button = root.querySelector('[data-cq-token-unit]');
    if (!button) return;
    const unit = getTokenUnit();
    button.textContent =
      unit === 'm' ? 'Token: M' : unit === 'yi' ? 'Token: 亿' : 'Token: 原始';
    button.dataset.cqActive = String(unit !== 'raw');
  }

  function updateFullscreenButton(root) {
    const button = root.querySelector('[data-cq-fullscreen]');
    if (!button) return;
    const isFullscreen = root.classList.contains('cq-fullscreen');
    button.textContent = isFullscreen ? '↙' : '⛶';
    button.title = isFullscreen ? '还原面板' : '全屏面板';
  }

  function setStatus(root, text) {
    root.querySelector('[data-cq-status]').textContent = text;
  }

  function usageColor(percent) {
    if (percent >= 85) return 'var(--cq-bad)';
    if (percent >= 65) return 'var(--cq-warn)';
    return 'var(--cq-good)';
  }

  function renderWindowMeter(windowRow) {
    if (!windowRow) return '';
    const pct = Math.max(0, Math.min(100, n(windowRow.usedPercent)));
    return `
      <div class="cq-card cq-wide">
        <div class="cq-meter">
          <div class="cq-ring" style="--cq-pct:${pct};--cq-ring-color:${usageColor(pct)}">
            <div class="cq-ring-label">已用</div>
            <span>${fmtPct(pct)}</span>
          </div>
          <div class="cq-stack">
            <div>
              <div class="cq-label">${escapeHtml(windowRow.name)}</div>
              <div class="cq-value">${fmtNum(100 - pct, 2)}% 剩余</div>
            </div>
            <div class="cq-kv"><span>下次重置</span><span>${escapeHtml(windowRow.resetAtLocal)}</span></div>
            <div class="cq-kv"><span>距离重置</span><span>${fmtNum(windowRow.resetAfterHours, 2)} 小时</span></div>
          </div>
        </div>
      </div>
    `;
  }

  function renderSummaryCards(data) {
    const additionalWindows = data.windows.filter((windowRow) => windowRow.scope === 'additional');
    const summaryWindows = [data.mainWeekly, data.mainFiveHour, ...additionalWindows].filter(Boolean);
    const estimate = data.weeklyEstimate;
    const rolling = data.ranges.rolling.summary;
    return `
      <div class="cq-grid">
        ${summaryWindows.map(renderWindowMeter).join('')}
        <div class="cq-card">
          <div class="cq-label">近${CONFIG.ROLLING_DAYS}天 Token</div>
          <div class="cq-value">${fmtTokensLong(rolling.tokens)}</div>
          <div class="cq-sub">${fmtTokenUnitHint(rolling.tokens) ? `${fmtTokenUnitHint(rolling.tokens)} · ` : ''}${fmtInt(rolling.turns)} 轮 · ${fmtInt(rolling.threads)} 线程</div>
        </div>
        <div class="cq-card">
          <div class="cq-label">反推周额度</div>
          <div class="cq-value">${fmtNum(estimate.totalCreditsWithoutResetDay ?? 0, 2)}</div>
          <div class="cq-sub">排除重置日口径 Credits</div>
        </div>
        <div class="cq-card">
          <div class="cq-label">估算剩余额度</div>
          <div class="cq-value">${fmtNum(estimate.remainingCreditsWithoutResetDay ?? 0, 2)}</div>
          <div class="cq-sub">约 $${fmtNum(estimate.remainingUsdWithoutResetDay ?? 0, 2)}</div>
        </div>
        <div class="cq-card">
          <div class="cq-label">日期桶</div>
          <div class="cq-value">${escapeHtml(data.apiTodayDate)}</div>
          <div class="cq-sub">${escapeHtml(data.browserTimeZone)} · ${escapeHtml(data.browserUtcOffset)}</div>
        </div>
      </div>
    `;
  }

  function table(headers, rows) {
    return `
      <div class="cq-table-wrap">
        <table>
          <thead><tr>${headers.map((h) => `<th>${escapeHtml(h.label)}</th>`).join('')}</tr></thead>
          <tbody>
            ${rows.map((row) => `
              <tr>
                ${headers.map((h) => `<td>${escapeHtml(h.format ? h.format(row[h.key], row) : row[h.key])}</td>`).join('')}
              </tr>
            `).join('')}
          </tbody>
        </table>
      </div>
    `;
  }

  function rowsToCsv(rows) {
    if (!rows.length) return '';
    const keys = Object.keys(rows[0]);
    const cell = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
    return [keys.map(cell).join(','), ...rows.map((r) => keys.map((k) => cell(r[k])).join(','))].join('\n');
  }

  function copyCsv(rows, button) {
    navigator.clipboard.writeText(rowsToCsv(rows)).then(() => {
      const old = button.textContent;
      button.textContent = '已复制';
      window.setTimeout(() => { button.textContent = old; }, 1200);
    });
  }

  function renderRange(root, data, key) {
    const range = data.ranges[key];
    root.dataset.cqRange = key;
    root.querySelectorAll('.cq-tab').forEach((btn) => {
      btn.classList.toggle('cq-active', btn.dataset.cqRange === key);
    });

    const summaryTarget = root.querySelector('[data-cq-range-summary]');
    const rowsTarget = root.querySelector('[data-cq-range-rows]');
    const clientsTarget = root.querySelector('[data-cq-range-clients]');

    summaryTarget.innerHTML = `
      <div class="cq-grid">
        <div class="cq-card"><div class="cq-label">范围</div><div class="cq-value" style="font-size:15px">${escapeHtml(range.summary.range)}</div><div class="cq-sub">${range.summary.dateBuckets} 个日期桶</div></div>
        <div class="cq-card"><div class="cq-label">累计 Token</div><div class="cq-value">${fmtTokensLong(range.summary.tokens)}</div><div class="cq-sub">${fmtTokenUnitHint(range.summary.tokens) ? `${fmtTokenUnitHint(range.summary.tokens)} · ` : ''}${fmtInt(range.summary.turns)} 轮 · ${fmtInt(range.summary.threads)} 线程</div></div>
        <div class="cq-card"><div class="cq-label">累计 Credits</div><div class="cq-value">${fmtNum(range.summary.credits, 6)}</div><div class="cq-sub">约 $${fmtNum(range.summary.usd, 2)}</div></div>
        <div class="cq-card"><div class="cq-label">API 日期</div><div class="cq-value" style="font-size:15px">${escapeHtml(range.summary.startDate)} → ${escapeHtml(range.summary.endExclusiveDate)}</div><div class="cq-sub">end_date 为排他边界</div></div>
      </div>
    `;

    rowsTarget.innerHTML = table([
      { key: 'date', label: '日期桶' },
      { key: 'tokens', label: tokenColumnLabel('Token'), format: fmtTokensCompact },
      { key: 'cachedInputTokens', label: tokenColumnLabel('缓存输入'), format: fmtTokensCompact },
      { key: 'uncachedInputTokens', label: tokenColumnLabel('非缓存输入'), format: fmtTokensCompact },
      { key: 'outputTokens', label: tokenColumnLabel('输出'), format: fmtTokensCompact },
      { key: 'turns', label: '轮数', format: fmtInt },
      { key: 'threads', label: '线程', format: fmtInt },
      { key: 'credits', label: 'Credits', format: (v) => fmtNum(v, 6) },
      { key: 'clientCredits', label: '客户端Credits' },
    ], range.rows);

    clientsTarget.innerHTML = table([
      { key: 'client', label: '客户端' },
      { key: 'tokens', label: tokenColumnLabel('Token'), format: fmtTokensCompact },
      { key: 'cachedInputTokens', label: tokenColumnLabel('缓存输入'), format: fmtTokensCompact },
      { key: 'uncachedInputTokens', label: tokenColumnLabel('非缓存输入'), format: fmtTokensCompact },
      { key: 'outputTokens', label: tokenColumnLabel('输出'), format: fmtTokensCompact },
      { key: 'turns', label: '轮数', format: fmtInt },
      { key: 'threads', label: '线程', format: fmtInt },
      { key: 'credits', label: 'Credits', format: (v) => fmtNum(v, 6) },
    ], range.clients);

    root.querySelector('[data-cq-copy-days]').onclick = (event) => copyCsv(range.rows, event.currentTarget);
    root.querySelector('[data-cq-copy-clients]').onclick = (event) => copyCsv(range.clients, event.currentTarget);
  }

  function render(root, data) {
    root.__cqData = data;
    updateTokenUnitButton(root);
    const activeRange = data.ranges[root.dataset.cqRange] ? root.dataset.cqRange : 'sinceReset';
    const body = root.querySelector('[data-cq-body]');
    body.innerHTML = `
      ${renderSummaryCards(data)}
      <div class="cq-section-head">
        <strong>用量区间</strong>
      </div>
      <div class="cq-tabs">
        ${Object.entries(data.ranges).map(([key, range]) => `
          <button class="cq-tab" type="button" data-cq-range="${key}">${escapeHtml(range.label)}</button>
        `).join('')}
      </div>
      <div data-cq-range-summary></div>
      <div class="cq-section-head">
        <strong>每日明细</strong>
        <button type="button" data-cq-copy-days>复制 CSV</button>
      </div>
      <div data-cq-range-rows></div>
      <div class="cq-section-head">
        <strong>客户端汇总</strong>
        <button type="button" data-cq-copy-clients>复制 CSV</button>
      </div>
      <div data-cq-range-clients></div>
      <div class="cq-sub" style="margin-top:10px">
        数据来自 ChatGPT 当前登录会话;脚本不展示、不保存 accessToken 或 Cookie。
      </div>
    `;
    root.querySelectorAll('.cq-tab').forEach((btn) => {
      btn.addEventListener('click', () => renderRange(root, data, btn.dataset.cqRange));
    });
    renderRange(root, data, activeRange);
    setStatus(root, `已更新:${new Date(data.fetchedAt).toLocaleTimeString()}`);
  }

  async function refresh(root) {
    const body = root.querySelector('[data-cq-body]');
    setStatus(root, '加载中');
    body.innerHTML = '<div class="cq-loading"><span class="cq-spinner"></span><span>正在读取 Codex analytics...</span></div>';
    try {
      const data = await loadCompassData();
      render(root, data);
    } catch (err) {
      setStatus(root, '加载失败');
      body.innerHTML = `<div class="cq-error">${escapeHtml(err?.message || err)}</div>`;
    }
  }

  function init() {
    if (document.getElementById(ROOT_ID)) return;
    addStyles();
    const root = getRoot();
    refresh(root);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})();