From 492245dcb9196b0169e227d9eae2ab91466c0058 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Mon, 3 Aug 2026 09:47:49 -0400 Subject: [PATCH] feat(codex-oauth): show per-account usage in Auth Center (#4887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(codex-oauth): show per-account usage in Auth Center Each ChatGPT (Codex OAuth) account under Settings → 认证 now displays its own subscription usage — reset countdowns and per-window progress bars — directly in the account list, instead of usage only being visible on the active provider card. - Add useCodexOauthQuotaByAccountId(accountId) and refactor useCodexOauthQuota to delegate to it (shared query key → cache reuse) - Add CodexOauthAccountQuota, a thin per-account wrapper that reuses the existing SubscriptionQuotaView expanded layout (same look and 5-state handling as provider cards), with a light spinner on first load - Render it under each account row in CodexOAuthSection; fetch once when the Auth Center opens, manual refresh available (no polling) Copilot is intentionally left out — same as before, this is Codex-only. * refactor(codex-oauth): stable async loading placeholder for account usage The account header (login + badges + actions) already renders independently of the usage query — the quota is fetched async via Tauri invoke + React Query, so the account never waits on it. Make that visually obvious and jump-free: while the usage loads, show a spinner inside a placeholder shaped like the final quota card (same rounded-xl / border / bg-card), so the card morphs smoothly into the data instead of popping in from an empty gap. * fix(codex-oauth): scope account quota to auth center --------- Co-authored-by: Jason --- src/components/CodexOauthAccountQuota.tsx | 55 +++++++++++++ .../providers/forms/CodexOAuthSection.tsx | 77 +++++++++++-------- src/components/settings/AuthCenterPanel.tsx | 2 +- src/lib/query/subscription.ts | 29 +++++-- tests/components/CodexOAuthSection.test.tsx | 74 ++++++++++++++++++ 5 files changed, 194 insertions(+), 43 deletions(-) create mode 100644 src/components/CodexOauthAccountQuota.tsx create mode 100644 tests/components/CodexOAuthSection.test.tsx diff --git a/src/components/CodexOauthAccountQuota.tsx b/src/components/CodexOauthAccountQuota.tsx new file mode 100644 index 000000000..7a42a2a90 --- /dev/null +++ b/src/components/CodexOauthAccountQuota.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Loader2 } from "lucide-react"; +import { useCodexOauthQuotaByAccountId } from "@/lib/query/subscription"; +import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter"; + +interface CodexOauthAccountQuotaProps { + /** cc-switch 自管的 ChatGPT 账号 ID */ + accountId: string; +} + +/** + * 设置 → 认证中心里,单个 ChatGPT (Codex OAuth) 账号的用量展示。 + * + * 直接按 accountId 查询 cc-switch 自管 OAuth token 的订阅额度,复用 + * `SubscriptionQuotaView` 的展开布局(进度条 + 重置倒计时 + 刷新按钮), + * 因此与供应商卡片里的额度展示保持完全一致的观感与状态处理。 + * + * 面板打开时拉取一次,不轮询;用户可点卡片内的刷新按钮手动更新。 + */ +const CodexOauthAccountQuota: React.FC = ({ + accountId, +}) => { + const { + data: quota, + isFetching: loading, + refetch, + } = useCodexOauthQuotaByAccountId(accountId, { + enabled: true, + autoQuery: false, + }); + + // 首次加载占位:账号头部由父组件独立渲染,这里只负责用量区。 + // 用量请求是异步的(Tauri invoke + React Query),加载期间给一个 + // 与最终额度卡片同形状(rounded-xl / border / bg-card)的转圈占位, + // 这样账号会立刻显示、用量数据到达后原地平滑替换,不产生跳版。 + if (loading && !quota) { + return ( +
+ +
+ ); + } + + return ( + + ); +}; + +export default CodexOauthAccountQuota; diff --git a/src/components/providers/forms/CodexOAuthSection.tsx b/src/components/providers/forms/CodexOAuthSection.tsx index 603939ad6..d94d90247 100644 --- a/src/components/providers/forms/CodexOAuthSection.tsx +++ b/src/components/providers/forms/CodexOAuthSection.tsx @@ -24,9 +24,12 @@ import { } from "lucide-react"; import { useCodexOauth } from "./hooks/useCodexOauth"; import { copyText } from "@/lib/clipboard"; +import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota"; interface CodexOAuthSectionProps { className?: string; + /** 是否展示每个账号的订阅额度 */ + showAccountQuota?: boolean; /** 当前选中的 ChatGPT 账号 ID */ selectedAccountId?: string | null; /** 账号选择回调 */ @@ -45,6 +48,7 @@ interface CodexOAuthSectionProps { */ export const CodexOAuthSection: React.FC = ({ className, + showAccountQuota = false, selectedAccountId, onAccountSelect, fastModeEnabled = false, @@ -178,47 +182,52 @@ export const CodexOAuthSection: React.FC = ({ {accounts.map((account) => (
-
- - {account.login} - {defaultAccountId === account.id && ( - - {t("codexOauth.defaultAccount", "默认")} - - )} - {selectedAccountId === account.id && ( - - {t("codexOauth.selected", "已选中")} - - )} -
-
- {defaultAccountId !== account.id && ( +
+
+ + {account.login} + {defaultAccountId === account.id && ( + + {t("codexOauth.defaultAccount", "默认")} + + )} + {selectedAccountId === account.id && ( + + {t("codexOauth.selected", "已选中")} + + )} +
+
+ {defaultAccountId !== account.id && ( + + )} - )} - +
+ {showAccountQuota && ( + + )}
))}
diff --git a/src/components/settings/AuthCenterPanel.tsx b/src/components/settings/AuthCenterPanel.tsx index 0726b5974..03989f17a 100644 --- a/src/components/settings/AuthCenterPanel.tsx +++ b/src/components/settings/AuthCenterPanel.tsx @@ -69,7 +69,7 @@ export function AuthCenterPanel() { - +
diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index ec84fd495..6ea583b0d 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -112,20 +112,18 @@ export interface UseCodexOauthQuotaOptions { } /** - * Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook + * Codex OAuth 订阅额度查询 hook(按账号 ID) * - * 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token, - * 而不是 Codex CLI 的 ~/.codex/auth.json。 - * - * Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。 + * 直接以 cc-switch 自管的 ChatGPT 账号 ID 查询额度,供认证中心里逐个账号 + * 展示用量时复用。Query key 与 `useCodexOauthQuota` 一致,绑定到同一账号的 + * 供应商卡片与账号列表会自动去重共享同一份请求缓存。 * accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。 */ -export function useCodexOauthQuota( - meta: ProviderMeta | undefined, +export function useCodexOauthQuotaByAccountId( + accountId: string | null, options: UseCodexOauthQuotaOptions = {}, ) { const { enabled = true, autoQuery = false } = options; - const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH); const query = useQuery({ queryKey: ["codex_oauth", "quota", accountId ?? "default"], queryFn: () => subscriptionApi.getCodexOauthQuota(accountId), @@ -140,6 +138,21 @@ export function useCodexOauthQuota( return useQuotaKeepLastGood(query, accountId ?? "default"); } +/** + * Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook + * + * 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token, + * 而不是 Codex CLI 的 ~/.codex/auth.json。账号 ID 从供应商 meta 的 + * authBinding 中解析,再委托给 `useCodexOauthQuotaByAccountId`。 + */ +export function useCodexOauthQuota( + meta: ProviderMeta | undefined, + options: UseCodexOauthQuotaOptions = {}, +) { + const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH); + return useCodexOauthQuotaByAccountId(accountId, options); +} + /** * xAI OAuth (SuperGrok 反代) 订阅额度查询 hook * diff --git a/tests/components/CodexOAuthSection.test.tsx b/tests/components/CodexOAuthSection.test.tsx new file mode 100644 index 000000000..8a40e073f --- /dev/null +++ b/tests/components/CodexOAuthSection.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection"; +import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; + +const mocks = vi.hoisted(() => ({ + useCodexOauth: vi.fn(), + renderAccountQuota: vi.fn(), +})); + +vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({ + useCodexOauth: mocks.useCodexOauth, +})); + +vi.mock("@/components/CodexOauthAccountQuota", () => ({ + default: ({ accountId }: { accountId: string }) => { + mocks.renderAccountQuota(accountId); + return
{accountId}
; + }, +})); + +vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({ + CopilotAuthSection: () =>
, +})); + +vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({ + XaiOAuthSection: () =>
, +})); + +describe("CodexOAuthSection", () => { + beforeEach(() => { + mocks.useCodexOauth.mockReturnValue({ + accounts: [ + { + id: "account-1", + provider: "codex_oauth", + login: "user@example.com", + avatar_url: null, + authenticated_at: 0, + is_default: true, + github_domain: "", + }, + ], + defaultAccountId: "account-1", + hasAnyAccount: true, + pollingState: "idle", + deviceCode: null, + error: null, + isPolling: false, + isAddingAccount: false, + isRemovingAccount: false, + isSettingDefaultAccount: false, + addAccount: vi.fn(), + removeAccount: vi.fn(), + setDefaultAccount: vi.fn(), + cancelAuth: vi.fn(), + logout: vi.fn(), + }); + }); + + it("does not render account quota by default", () => { + render(); + + expect(mocks.renderAccountQuota).not.toHaveBeenCalled(); + expect(screen.queryByTestId("account-quota")).not.toBeInTheDocument(); + }); + + it("renders account quota in Auth Center", () => { + render(); + + expect(mocks.renderAccountQuota).toHaveBeenCalledWith("account-1"); + expect(screen.getByTestId("account-quota")).toHaveTextContent("account-1"); + }); +});