mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc180a3d9d | |||
| c7a2bff78b | |||
| d52ab6c5f4 | |||
| 0f3991efc3 |
@@ -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>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AuthCenterPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodexOAuthSection />
|
||||
<CodexOAuthSection showAccountQuota />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user