Compare commits

...

4 Commits

Author SHA1 Message Date
saladday bc180a3d9d fix(codex-oauth): scope account quota to auth center 2026-07-14 02:57:35 -04:00
saladday c7a2bff78b Merge remote-tracking branch 'origin/main' into pr-4887
# Conflicts:
#	src/lib/query/subscription.ts
2026-07-14 02:56:31 -04:00
SaladDay d52ab6c5f4 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.
2026-07-01 17:33:35 +00:00
SaladDay 0f3991efc3 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.
2026-07-01 17:07:04 +00:00
5 changed files with 190 additions and 43 deletions
+55
View File
@@ -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<CodexOauthAccountQuotaProps> = ({
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 (
<div className="mt-3 flex items-center justify-center rounded-xl border border-border-default bg-card py-5 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint="codex_oauth"
inline={false}
/>
);
};
export default CodexOauthAccountQuota;
@@ -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<CodexOAuthSectionProps> = ({
className,
showAccountQuota = false,
selectedAccountId,
onAccountSelect,
fastModeEnabled = false,
@@ -178,47 +182,52 @@ 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>
{showAccountQuota && (
<CodexOauthAccountQuota accountId={account.id} />
)}
</div>
))}
</div>
+1 -1
View File
@@ -67,7 +67,7 @@ export function AuthCenterPanel() {
</div>
</div>
<CodexOAuthSection />
<CodexOAuthSection showAccountQuota />
</section>
</div>
);
+21 -8
View File
@@ -111,20 +111,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),
@@ -138,3 +136,18 @@ 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);
}
@@ -0,0 +1,70 @@
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 <div data-testid="account-quota">{accountId}</div>;
},
}));
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
CopilotAuthSection: () => <div />,
}));
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(<CodexOAuthSection />);
expect(mocks.renderAccountQuota).not.toHaveBeenCalled();
expect(screen.queryByTestId("account-quota")).not.toBeInTheDocument();
});
it("renders account quota in Auth Center", () => {
render(<AuthCenterPanel />);
expect(mocks.renderAccountQuota).toHaveBeenCalledWith("account-1");
expect(screen.getByTestId("account-quota")).toHaveTextContent("account-1");
});
});