feat: display subscription quota for Codex OAuth provider cards

Codex OAuth (ChatGPT Plus/Pro) providers previously fell through to the
default UsageFooter branch and showed no quota at all, while Copilot and
official Codex providers already had a wham/usage-backed quota footer.

This wires up the same five-hour / seven-day tier badges for codex_oauth
provider cards by reusing the existing query_codex_quota function and
SubscriptionQuotaFooter rendering, parameterized to keep both the CLI
credential path ("codex") and the cc-switch managed OAuth path
("codex_oauth") working from a single source of truth.

- Parameterize services::subscription::query_codex_quota with tool_label
  and expired_message; promote SubscriptionQuota constructors to
  pub(crate). The CLI path keeps its existing "codex" label and the
  "re-login with Codex CLI" message; the new path passes "codex_oauth"
  and a cc-switch-specific re-login hint.
- Add a new get_codex_oauth_quota Tauri command in commands/codex_oauth.rs
  that resolves the ChatGPT account (explicit binding > default account
  > not_found), pulls a valid access_token from CodexOAuthManager
  (auto-refresh handled), and delegates to query_codex_quota.
- Extract SubscriptionQuotaFooter's render body into a pure
  SubscriptionQuotaView component (props: quota / loading / refetch /
  appIdForExpiredHint / inline). The existing SubscriptionQuotaFooter
  becomes a thin wrapper with identical props and behavior, so
  CopilotQuotaFooter and the official Claude/Codex/Gemini paths are
  untouched. This avoids duplicating ~280 lines of five-state rendering.
- Add CodexOauthQuotaFooter, a 38-line wrapper that calls the new
  useCodexOauthQuota hook and forwards to SubscriptionQuotaView.
- ProviderCard inserts an isCodexOauth branch between isCopilot and
  isOfficial, keyed off PROVIDER_TYPES.CODEX_OAUTH (newly added to
  config/constants.ts to centralize the previously scattered string).
- Frontend hook caches per (codex_oauth, accountId) so multiple cards
  bound to the same ChatGPT account share one fetch via react-query
  dedup; cards bound to different accounts get independent fetches.
- No new i18n keys: existing subscription.fiveHour / sevenDay / expired /
  refresh / queryFailed / expiredHint are reused.
This commit is contained in:
Jason
2026-04-07 12:11:05 +08:00
parent 6a34253934
commit d164191bd1
9 changed files with 204 additions and 23 deletions
+38
View File
@@ -0,0 +1,38 @@
import React from "react";
import type { ProviderMeta } from "@/types";
import { useCodexOauthQuota } from "@/lib/query/subscription";
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
interface CodexOauthQuotaFooterProps {
meta?: ProviderMeta;
inline?: boolean;
}
/**
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度 footer
*
* 复用 SubscriptionQuotaView 的全部渲染逻辑(5 状态 × inline/expanded)。
* 数据源切换为 cc-switch 自管的 OAuth token 而非 Codex CLI 凭据。
*/
const CodexOauthQuotaFooter: React.FC<CodexOauthQuotaFooterProps> = ({
meta,
inline = false,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useCodexOauthQuota(meta, true);
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint="codex_oauth"
inline={inline}
/>
);
};
export default CodexOauthQuotaFooter;
+49 -9
View File
@@ -3,13 +3,22 @@ import { RefreshCw, AlertCircle, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
import { useSubscriptionQuota } from "@/lib/query/subscription";
import type { QuotaTier } from "@/types/subscription";
import type { QuotaTier, SubscriptionQuota } from "@/types/subscription";
interface SubscriptionQuotaFooterProps {
appId: AppId;
inline?: boolean;
}
interface SubscriptionQuotaViewProps {
quota: SubscriptionQuota | undefined;
loading: boolean;
refetch: () => void;
/** 用于 `subscription.expiredHint` 的 {tool} 插值;解耦了 hook 的 appId */
appIdForExpiredHint: string;
inline?: boolean;
}
/** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */
export const TIER_I18N_KEYS: Record<string, string> = {
five_hour: "subscription.fiveHour",
@@ -78,16 +87,22 @@ function formatRelativeTime(
return t("usage.daysAgo", { count: Math.floor(diff / 86400) });
}
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
appId,
/**
* 纯展示组件:渲染 SubscriptionQuota 的 5 种状态(not_found / parse_error /
* expired / API 失败 / 成功),支持 inline / expanded 两种布局。
*
* 数据源由调用方 hook 注入,方便不同的额度后端复用同一套渲染逻辑:
* - `SubscriptionQuotaFooter`CLI 凭据路径,by appId
* - `CodexOauthQuotaFooter`cc-switch 自管 OAuth 路径,by ChatGPT account
*/
export const SubscriptionQuotaView: React.FC<SubscriptionQuotaViewProps> = ({
quota,
loading,
refetch,
appIdForExpiredHint,
inline = false,
}) => {
const { t } = useTranslation();
const {
data: quota,
isFetching: loading,
refetch,
} = useSubscriptionQuota(appId, true);
// 定期更新相对时间显示
const [now, setNow] = React.useState(Date.now());
@@ -131,7 +146,7 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
<div>
<span className="font-medium">{t("subscription.expired")}</span>
<span className="ml-2 text-amber-500/70 dark:text-amber-400/70">
{t("subscription.expiredHint", { tool: appId })}
{t("subscription.expiredHint", { tool: appIdForExpiredHint })}
</span>
</div>
</div>
@@ -364,4 +379,29 @@ const TierBar: React.FC<{
);
};
/**
* CLI 凭据路径下的薄 wrapper:通过 useSubscriptionQuota(appId) 自取数据
* 后转发到 SubscriptionQuotaView。对外 props/行为与重构前完全一致。
*/
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
appId,
inline = false,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useSubscriptionQuota(appId, true);
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint={appId}
inline={inline}
/>
);
};
export default SubscriptionQuotaFooter;
@@ -13,6 +13,7 @@ import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
import { PROVIDER_TYPES } from "@/config/constants";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
@@ -177,6 +178,8 @@ export function ProviderCard({
const isCopilot =
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
provider.meta?.usage_script?.templateType === "github_copilot";
const isCodexOauth =
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
@@ -355,6 +358,8 @@ export function ProviderCard({
<div className="flex items-center gap-1">
{isCopilot ? (
<CopilotQuotaFooter meta={provider.meta} inline={true} />
) : isCodexOauth ? (
<CodexOauthQuotaFooter meta={provider.meta} inline={true} />
) : isOfficial ? (
<SubscriptionQuotaFooter appId={appId} inline={true} />
) : hasMultiplePlans ? (