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.
This commit is contained in:
SaladDay
2026-07-01 17:07:04 +00:00
parent 8d1b3306d0
commit 0f3991efc3
3 changed files with 112 additions and 42 deletions
+53
View File
@@ -0,0 +1,53 @@
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<CodexOauthAccountQuotaProps> = ({
accountId,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useCodexOauthQuotaByAccountId(accountId, {
enabled: true,
autoQuery: false,
});
// 首次加载占位:SubscriptionQuotaView 在 quota 未就绪时返回 null
// 这里给一个语言无关的 spinner,避免开面板后出现空白跳变。
if (loading && !quota) {
return (
<div className="mt-3 flex items-center justify-center py-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint="codex_oauth"
inline={false}
/>
);
};
export default CodexOauthAccountQuota;
@@ -24,6 +24,7 @@ import {
} from "lucide-react";
import { useCodexOauth } from "./hooks/useCodexOauth";
import { copyText } from "@/lib/clipboard";
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
interface CodexOAuthSectionProps {
className?: string;
@@ -178,47 +179,50 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
className="space-y-2 p-2 rounded-md border bg-muted/30"
>
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
{t("codexOauth.setAsDefault", "设为默认")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
>
{t("codexOauth.setAsDefault", "设为默认")}
<X className="h-4 w-4" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<CodexOauthAccountQuota accountId={account.id} />
</div>
))}
</div>
+21 -8
View File
@@ -45,20 +45,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);
return useQuery({
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
@@ -70,3 +68,18 @@ export function useCodexOauthQuota(
retry: 1,
});
}
/**
* 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);
}