mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +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";
|
} from "lucide-react";
|
||||||
import { useCodexOauth } from "./hooks/useCodexOauth";
|
import { useCodexOauth } from "./hooks/useCodexOauth";
|
||||||
import { copyText } from "@/lib/clipboard";
|
import { copyText } from "@/lib/clipboard";
|
||||||
|
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
|
||||||
|
|
||||||
interface CodexOAuthSectionProps {
|
interface CodexOAuthSectionProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 是否展示每个账号的订阅额度 */
|
||||||
|
showAccountQuota?: boolean;
|
||||||
/** 当前选中的 ChatGPT 账号 ID */
|
/** 当前选中的 ChatGPT 账号 ID */
|
||||||
selectedAccountId?: string | null;
|
selectedAccountId?: string | null;
|
||||||
/** 账号选择回调 */
|
/** 账号选择回调 */
|
||||||
@@ -45,6 +48,7 @@ interface CodexOAuthSectionProps {
|
|||||||
*/
|
*/
|
||||||
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||||
className,
|
className,
|
||||||
|
showAccountQuota = false,
|
||||||
selectedAccountId,
|
selectedAccountId,
|
||||||
onAccountSelect,
|
onAccountSelect,
|
||||||
fastModeEnabled = false,
|
fastModeEnabled = false,
|
||||||
@@ -178,47 +182,52 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
|||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<User className="h-5 w-5 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{account.login}</span>
|
<User className="h-5 w-5 text-muted-foreground" />
|
||||||
{defaultAccountId === account.id && (
|
<span className="text-sm font-medium">{account.login}</span>
|
||||||
<Badge variant="secondary" className="text-xs">
|
{defaultAccountId === account.id && (
|
||||||
{t("codexOauth.defaultAccount", "默认")}
|
<Badge variant="secondary" className="text-xs">
|
||||||
</Badge>
|
{t("codexOauth.defaultAccount", "默认")}
|
||||||
)}
|
</Badge>
|
||||||
{selectedAccountId === account.id && (
|
)}
|
||||||
<Badge variant="outline" className="text-xs">
|
{selectedAccountId === account.id && (
|
||||||
{t("codexOauth.selected", "已选中")}
|
<Badge variant="outline" className="text-xs">
|
||||||
</Badge>
|
{t("codexOauth.selected", "已选中")}
|
||||||
)}
|
</Badge>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
{defaultAccountId !== account.id && (
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="h-7 px-2 text-xs text-muted-foreground"
|
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||||
onClick={() => setDefaultAccount(account.id)}
|
onClick={(e) => handleRemoveAccount(account.id, e)}
|
||||||
disabled={isSettingDefaultAccount}
|
disabled={isRemovingAccount}
|
||||||
|
title={t("codexOauth.removeAccount", "移除账号")}
|
||||||
>
|
>
|
||||||
{t("codexOauth.setAsDefault", "设为默认")}
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function AuthCenterPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CodexOAuthSection />
|
<CodexOAuthSection showAccountQuota />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,20 +111,18 @@ export interface UseCodexOauthQuotaOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
|
* Codex OAuth 订阅额度查询 hook(按账号 ID)
|
||||||
*
|
*
|
||||||
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token,
|
* 直接以 cc-switch 自管的 ChatGPT 账号 ID 查询额度,供认证中心里逐个账号
|
||||||
* 而不是 Codex CLI 的 ~/.codex/auth.json。
|
* 展示用量时复用。Query key 与 `useCodexOauthQuota` 一致,绑定到同一账号的
|
||||||
*
|
* 供应商卡片与账号列表会自动去重共享同一份请求缓存。
|
||||||
* Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。
|
|
||||||
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
||||||
*/
|
*/
|
||||||
export function useCodexOauthQuota(
|
export function useCodexOauthQuotaByAccountId(
|
||||||
meta: ProviderMeta | undefined,
|
accountId: string | null,
|
||||||
options: UseCodexOauthQuotaOptions = {},
|
options: UseCodexOauthQuotaOptions = {},
|
||||||
) {
|
) {
|
||||||
const { enabled = true, autoQuery = false } = options;
|
const { enabled = true, autoQuery = false } = options;
|
||||||
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
||||||
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
||||||
@@ -138,3 +136,18 @@ export function useCodexOauthQuota(
|
|||||||
|
|
||||||
return useQuotaKeepLastGood(query, accountId ?? "default");
|
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