mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
15d5dbe065
Add SuperGrok subscription usage display, following the existing Claude Code / Codex official-subscription pattern (protocol ported from steipete/CodexBar): - New subscription_grok service: reads Grok CLI credentials from ~/.grok/auth.json, calls the grok.com GrokBuildBilling gRPC-web endpoint, and parses the response via heuristic protobuf scanning (used percent, reset time, zero-usage special case) - Transient failures (network errors, HTTP 408, gRPC deadline/ cancelled) propagate as Err so the frontend retries and keeps the last good value; auth failures map to Expired with a re-login hint - Tier naming by reset distance: weekly limit, monthly, or a new "credits" tier (i18n added for zh/en/ja/zh-TW; tray shows "c") - New get_xai_oauth_quota command: xai_oauth providers (managed SuperGrok OAuth accounts) query the same billing endpoint with their bound account token; ProviderCard auto-renders the quota footer for them and hides the usage-script entry, and the tray / usage-script path routes xai_oauth providers to the managed account instead of the host app's CLI credentials - UsageScriptModal: drop the config-content heuristic for official detection; category === "official" is the single source of truth Claude-Session: https://claude.ai/code/session_01LSNvhEfuoJHaQLZcYQgBU5
627 lines
24 KiB
TypeScript
627 lines
24 KiB
TypeScript
import { useMemo, useState, useEffect } from "react";
|
||
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||
import { useTranslation } from "react-i18next";
|
||
import type {
|
||
DraggableAttributes,
|
||
DraggableSyntheticListeners,
|
||
} from "@dnd-kit/core";
|
||
import type { Provider } from "@/types";
|
||
import type { AppId } from "@/lib/api";
|
||
import { cn } from "@/lib/utils";
|
||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||
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 XaiOauthQuotaFooter from "@/components/XaiOauthQuotaFooter";
|
||
import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants";
|
||
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
|
||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||
import {
|
||
extractCodexBaseUrl,
|
||
extractCodexExperimentalBearerToken,
|
||
} from "@/utils/providerConfigUtils";
|
||
import {
|
||
supportsOfficialProxyTakeover,
|
||
providerNeedsRouting,
|
||
} from "@/utils/providerCapabilities";
|
||
import { useProviderHealth } from "@/lib/query/failover";
|
||
import { useUsageQuery } from "@/lib/query/queries";
|
||
import { resolveProviderIcon } from "@/utils/providerIcon";
|
||
|
||
interface DragHandleProps {
|
||
attributes: DraggableAttributes;
|
||
listeners: DraggableSyntheticListeners;
|
||
isDragging: boolean;
|
||
}
|
||
|
||
interface ProviderCardProps {
|
||
provider: Provider;
|
||
isCurrent: boolean;
|
||
appId: AppId;
|
||
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||
isOmo?: boolean;
|
||
isOmoSlim?: boolean;
|
||
onSwitch: (provider: Provider) => void;
|
||
onEdit: (provider: Provider) => void;
|
||
onDelete: (provider: Provider) => void;
|
||
onRemoveFromConfig?: (provider: Provider) => void;
|
||
onDisableOmo?: () => void;
|
||
onDisableOmoSlim?: () => void;
|
||
onConfigureUsage: (provider: Provider) => void;
|
||
onOpenWebsite: (url: string) => void;
|
||
onDuplicate: (provider: Provider) => void;
|
||
onTest?: (provider: Provider) => void;
|
||
onOpenTerminal?: (provider: Provider) => void;
|
||
isTesting?: boolean;
|
||
isProxyRunning: boolean;
|
||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||
dragHandleProps?: DragHandleProps;
|
||
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
||
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
||
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||
// OpenClaw: default model
|
||
isDefaultModel?: boolean;
|
||
onSetAsDefault?: () => void;
|
||
}
|
||
|
||
/** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */
|
||
function isOfficialProvider(provider: Provider, appId: AppId): boolean {
|
||
if (provider.category === "official") {
|
||
return true;
|
||
}
|
||
|
||
const config = provider.settingsConfig as Record<string, any>;
|
||
if (appId === "claude") {
|
||
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||
return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === "");
|
||
}
|
||
if (appId === "codex") {
|
||
// 无 OPENAI_API_KEY → 使用 Codex CLI 内置 OAuth(官方)
|
||
const apiKey = config?.auth?.OPENAI_API_KEY;
|
||
const bearerToken =
|
||
typeof config?.config === "string"
|
||
? extractCodexExperimentalBearerToken(config.config)
|
||
: undefined;
|
||
return (
|
||
!bearerToken &&
|
||
(!apiKey || (typeof apiKey === "string" && apiKey.trim() === ""))
|
||
);
|
||
}
|
||
if (appId === "gemini") {
|
||
// 无 GEMINI_API_KEY 且无 GOOGLE_GEMINI_BASE_URL → Google OAuth 官方模式
|
||
const apiKey = config?.env?.GEMINI_API_KEY;
|
||
const baseUrl = config?.env?.GOOGLE_GEMINI_BASE_URL;
|
||
return (
|
||
(!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) &&
|
||
(!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""))
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||
if (provider.notes?.trim()) {
|
||
return provider.notes.trim();
|
||
}
|
||
|
||
if (provider.websiteUrl) {
|
||
return provider.websiteUrl;
|
||
}
|
||
|
||
const config = provider.settingsConfig;
|
||
|
||
if (config && typeof config === "object") {
|
||
const envBase =
|
||
(config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL ||
|
||
(config as Record<string, any>)?.env?.GOOGLE_GEMINI_BASE_URL;
|
||
if (typeof envBase === "string" && envBase.trim()) {
|
||
return envBase;
|
||
}
|
||
|
||
const baseUrl = (config as Record<string, any>)?.config;
|
||
|
||
if (typeof baseUrl === "string" && baseUrl.includes("base_url")) {
|
||
const extractedBaseUrl = extractCodexBaseUrl(baseUrl);
|
||
if (extractedBaseUrl) {
|
||
return extractedBaseUrl;
|
||
}
|
||
}
|
||
}
|
||
|
||
return fallbackText;
|
||
};
|
||
|
||
export function ProviderCard({
|
||
provider,
|
||
isCurrent,
|
||
appId,
|
||
isInConfig = true,
|
||
isOmo = false,
|
||
isOmoSlim = false,
|
||
onSwitch,
|
||
onEdit,
|
||
onDelete,
|
||
onRemoveFromConfig,
|
||
onDisableOmo,
|
||
onDisableOmoSlim,
|
||
onConfigureUsage,
|
||
onOpenWebsite,
|
||
onDuplicate,
|
||
onTest,
|
||
onOpenTerminal,
|
||
isTesting,
|
||
isProxyRunning,
|
||
isProxyTakeover = false,
|
||
dragHandleProps,
|
||
isAutoFailoverEnabled = false,
|
||
failoverPriority,
|
||
isInFailoverQueue = false,
|
||
onToggleFailover,
|
||
activeProviderId,
|
||
// OpenClaw: default model
|
||
isDefaultModel,
|
||
onSetAsDefault,
|
||
}: ProviderCardProps) {
|
||
const { t } = useTranslation();
|
||
|
||
// OMO and OMO Slim share the same card behavior
|
||
const isAnyOmo = isOmo || isOmoSlim;
|
||
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
|
||
const isAdditiveMode = appId === "opencode" && !isAnyOmo;
|
||
|
||
const { data: health } = useProviderHealth(provider.id, appId);
|
||
|
||
const fallbackUrlText = t("provider.notConfigured", {
|
||
defaultValue: "未配置接口地址",
|
||
});
|
||
|
||
const displayUrl = useMemo(() => {
|
||
return extractApiUrl(provider, fallbackUrlText);
|
||
}, [provider, fallbackUrlText]);
|
||
|
||
const isClickableUrl = useMemo(() => {
|
||
if (provider.notes?.trim()) {
|
||
return false;
|
||
}
|
||
if (displayUrl === fallbackUrlText) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}, [provider.notes, displayUrl, fallbackUrlText]);
|
||
|
||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||
const isOfficial = isOfficialProvider(provider, appId);
|
||
const supportsOfficialSubscription =
|
||
isOfficial && ["claude", "codex", "gemini", "grokbuild"].includes(appId);
|
||
const isOfficialSubscriptionUsage =
|
||
provider.meta?.usage_script?.templateType ===
|
||
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
|
||
const officialSubscriptionEnabled =
|
||
supportsOfficialSubscription && usageEnabled && isOfficialSubscriptionUsage;
|
||
// 官方判定只认显式 category === "official"(SSOT),不回退 isOfficial 的空字段启发式。
|
||
// 理由(此判定曾在「纯 category ↔ category+isOfficial 回退」间反复,结论钉死于此):
|
||
// 1) 封号保护是高代价决策,不该建立在「base_url/key 缺失」这种脆弱信号上——它无法区分
|
||
// 「想直连官方」与「自定义但还没填完」,两者都表现为字段为空,必然误伤后者。
|
||
// 2) 启发式在 UI 多拦的部分,执行层 useProviderActions.ts 也只认 category === "official"、
|
||
// 并不兑现(绕过 UI 即可切换)→ 属虚保护,却以误伤 category 缺失的自定义供应商为代价。
|
||
// 3) 预设导入的官方一定带 category="official",category 缺失的「真官方」现实中≈不存在。
|
||
// 真官方就该有显式 category;手动新建官方应引导标注,而不是靠空字段猜。
|
||
const supportsOfficialRouting = supportsOfficialProxyTakeover(
|
||
appId,
|
||
provider,
|
||
);
|
||
const isOfficialBlockedByProxy =
|
||
isProxyTakeover &&
|
||
provider.category === "official" &&
|
||
!supportsOfficialRouting;
|
||
const isCopilot =
|
||
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
|
||
provider.meta?.usage_script?.templateType === "github_copilot";
|
||
// Hermes v12+ overlay entries live under the `providers:` dict and are
|
||
// read-only here — writes have to go through Hermes Web UI.
|
||
const isHermesReadOnly =
|
||
appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig);
|
||
const isCodexOauth =
|
||
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
|
||
// xAI OAuth (SuperGrok 反代):额度经自管 OAuth token 自动显示,与 codex_oauth 同构
|
||
const isXaiOauth = provider.meta?.providerType === PROVIDER_TYPES.XAI_OAUTH;
|
||
// 统一权威谓词(详见 providerNeedsRouting):以 providerType 为准,不受
|
||
// apiFormat 被改动/缺省影响。此 badge 仅在 Codex 视图渲染,故加 appId 守卫。
|
||
const codexNeedsRouting =
|
||
appId === "codex" && providerNeedsRouting(appId, provider);
|
||
// 获取用量数据以判断是否有多套餐
|
||
// 累加模式应用(OpenCode/OpenClaw/Hermes):使用 isInConfig 代替 isCurrent
|
||
const shouldAutoQuery =
|
||
appId === "opencode" || appId === "openclaw" || appId === "hermes"
|
||
? isInConfig
|
||
: isCurrent;
|
||
const autoQueryInterval = shouldAutoQuery
|
||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||
: 0;
|
||
|
||
const { data: usage } = useUsageQuery(provider.id, appId, {
|
||
enabled: usageEnabled && !isOfficial && !isOfficialSubscriptionUsage,
|
||
autoQueryInterval,
|
||
});
|
||
|
||
const isTokenPlan =
|
||
provider.meta?.usage_script?.templateType === "token_plan";
|
||
const hasMultiplePlans =
|
||
usage?.success && usage.data && usage.data.length > 1 && !isTokenPlan;
|
||
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (hasMultiplePlans) {
|
||
setIsExpanded(true);
|
||
}
|
||
}, [hasMultiplePlans]);
|
||
|
||
const handleOpenWebsite = () => {
|
||
if (!isClickableUrl) {
|
||
return;
|
||
}
|
||
onOpenWebsite(displayUrl);
|
||
};
|
||
|
||
// 判断是否是"当前使用中"的供应商
|
||
// - OMO/OMO Slim 供应商:使用 isCurrent
|
||
// - OpenClaw:使用默认模型归属的 provider 作为当前项(蓝色边框)
|
||
// - OpenCode(非 OMO):不存在"当前"概念,返回 false
|
||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||
// - 普通模式:isCurrent
|
||
const isActiveProvider = isAnyOmo
|
||
? isCurrent
|
||
: appId === "openclaw"
|
||
? Boolean(isDefaultModel)
|
||
: appId === "opencode"
|
||
? false
|
||
: isAutoFailoverEnabled
|
||
? activeProviderId === provider.id
|
||
: isCurrent;
|
||
|
||
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||
const hasPersistentConfigHighlight = isAdditiveMode && isInConfig;
|
||
const shouldUseBlue =
|
||
(isAnyOmo && isActiveProvider) ||
|
||
(!isAnyOmo &&
|
||
!isProxyTakeover &&
|
||
(isActiveProvider || hasPersistentConfigHighlight));
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||
"bg-card text-card-foreground group",
|
||
isAutoFailoverEnabled || isProxyTakeover
|
||
? "hover:border-emerald-500/50"
|
||
: "hover:border-border-active",
|
||
shouldUseGreen &&
|
||
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||
!(isActiveProvider || hasPersistentConfigHighlight) &&
|
||
"hover:shadow-sm",
|
||
dragHandleProps?.isDragging &&
|
||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||
shouldUseGreen && "from-emerald-500/10",
|
||
shouldUseBlue && "from-blue-500/10",
|
||
!shouldUseGreen && !shouldUseBlue && "from-primary/10",
|
||
isActiveProvider || hasPersistentConfigHighlight
|
||
? "opacity-100"
|
||
: "opacity-0",
|
||
)}
|
||
/>
|
||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"-ml-1.5 flex-shrink-0 cursor-grab active:cursor-grabbing p-1.5",
|
||
"text-muted-foreground/50 hover:text-muted-foreground transition-colors",
|
||
dragHandleProps?.isDragging && "cursor-grabbing",
|
||
)}
|
||
aria-label={t("provider.dragHandle")}
|
||
{...(dragHandleProps?.attributes ?? {})}
|
||
{...(dragHandleProps?.listeners ?? {})}
|
||
>
|
||
<GripVertical className="h-4 w-4" />
|
||
</button>
|
||
|
||
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||
<ProviderIcon
|
||
icon={resolveProviderIcon(
|
||
appId,
|
||
provider.icon,
|
||
provider.iconColor,
|
||
)}
|
||
name={provider.name}
|
||
color={provider.iconColor}
|
||
size={20}
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1 space-y-1">
|
||
<div className="flex flex-wrap items-center gap-2 min-h-7">
|
||
<h3 className="text-base font-semibold leading-none">
|
||
{provider.name}
|
||
</h3>
|
||
|
||
{isOmo && (
|
||
<span className="inline-flex items-center rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
|
||
OMO
|
||
</span>
|
||
)}
|
||
|
||
{isOmoSlim && (
|
||
<span className="inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||
Slim
|
||
</span>
|
||
)}
|
||
|
||
{appId === "claude-desktop" &&
|
||
providerNeedsRouting(appId, provider) && (
|
||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||
{t("claudeDesktop.modeProxy", {
|
||
defaultValue: "需要路由",
|
||
})}
|
||
</span>
|
||
)}
|
||
|
||
{appId === "claude" && providerNeedsRouting(appId, provider) && (
|
||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||
{t("claudeCode.needsRouting", {
|
||
defaultValue: "需要路由",
|
||
})}
|
||
</span>
|
||
)}
|
||
|
||
{codexNeedsRouting && (
|
||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||
{t("codex.needsRouting", {
|
||
defaultValue: "需要路由",
|
||
})}
|
||
</span>
|
||
)}
|
||
|
||
{appId === "claude" && provider.category === "official" && (
|
||
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
|
||
{t("claudeCode.noRoutingSupport", {
|
||
defaultValue: "不支持路由",
|
||
})}
|
||
</span>
|
||
)}
|
||
|
||
{appId === "codex" && supportsOfficialRouting && (
|
||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||
{isProxyTakeover
|
||
? t("codex.officialRouting", {
|
||
defaultValue: "官方账号路由",
|
||
})
|
||
: t("codex.nativeLogin", {
|
||
defaultValue: "Codex 登录",
|
||
})}
|
||
</span>
|
||
)}
|
||
|
||
{appId === "codex" &&
|
||
provider.category === "official" &&
|
||
!supportsOfficialRouting && (
|
||
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
|
||
{t("codex.noRoutingSupport", {
|
||
defaultValue: "不支持路由",
|
||
})}
|
||
</span>
|
||
)}
|
||
|
||
{isProxyRunning && isInFailoverQueue && health && (
|
||
<ProviderHealthBadge
|
||
consecutiveFailures={health.consecutive_failures}
|
||
isHealthy={health.is_healthy}
|
||
/>
|
||
)}
|
||
|
||
{isAutoFailoverEnabled &&
|
||
isInFailoverQueue &&
|
||
failoverPriority && (
|
||
<FailoverPriorityBadge priority={failoverPriority} />
|
||
)}
|
||
|
||
{provider.category === "third_party" &&
|
||
provider.meta?.isPartner && (
|
||
<span
|
||
className="text-yellow-500 dark:text-yellow-400"
|
||
title={t("provider.officialPartner", {
|
||
defaultValue: "官方合作伙伴",
|
||
})}
|
||
>
|
||
⭐
|
||
</span>
|
||
)}
|
||
|
||
{isHermesReadOnly && (
|
||
<span
|
||
className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200"
|
||
title={t("provider.managedByHermesHint", {
|
||
defaultValue: "由 Hermes 管理,请在 Hermes Web UI 中编辑",
|
||
})}
|
||
>
|
||
{t("provider.managedByHermes", {
|
||
defaultValue: "Hermes Managed",
|
||
})}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{displayUrl && (
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenWebsite}
|
||
className={cn(
|
||
"inline-flex max-w-full items-center overflow-hidden text-left text-sm",
|
||
isClickableUrl
|
||
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
|
||
: "text-muted-foreground cursor-default",
|
||
)}
|
||
title={displayUrl}
|
||
disabled={!isClickableUrl}
|
||
>
|
||
<span className="min-w-0 truncate">{displayUrl}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center ml-auto min-w-0 gap-3">
|
||
<div className="ml-auto">
|
||
<div className="flex items-center gap-1">
|
||
{isCopilot ? (
|
||
<CopilotQuotaFooter
|
||
meta={provider.meta}
|
||
inline={true}
|
||
isCurrent={isCurrent}
|
||
/>
|
||
) : isCodexOauth ? (
|
||
<CodexOauthQuotaFooter
|
||
meta={provider.meta}
|
||
inline={true}
|
||
isCurrent={isCurrent}
|
||
/>
|
||
) : isXaiOauth ? (
|
||
<XaiOauthQuotaFooter
|
||
meta={provider.meta}
|
||
inline={true}
|
||
isCurrent={isCurrent}
|
||
/>
|
||
) : isOfficial ? (
|
||
officialSubscriptionEnabled ? (
|
||
<SubscriptionQuotaFooter
|
||
appId={appId}
|
||
inline={true}
|
||
isCurrent={isCurrent}
|
||
autoQueryInterval={
|
||
provider.meta?.usage_script?.autoQueryInterval ?? 0
|
||
}
|
||
/>
|
||
) : null
|
||
) : hasMultiplePlans ? (
|
||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||
<span className="font-medium">
|
||
{t("usage.multiplePlans", {
|
||
count: usage?.data?.length || 0,
|
||
defaultValue: `${usage?.data?.length || 0} 个套餐`,
|
||
})}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<UsageFooter
|
||
provider={provider}
|
||
providerId={provider.id}
|
||
appId={appId}
|
||
usageEnabled={usageEnabled}
|
||
isCurrent={isCurrent}
|
||
isInConfig={isInConfig}
|
||
inline={true}
|
||
/>
|
||
)}
|
||
{hasMultiplePlans && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsExpanded(!isExpanded);
|
||
}}
|
||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400 flex-shrink-0"
|
||
title={
|
||
isExpanded
|
||
? t("usage.collapse", { defaultValue: "收起" })
|
||
: t("usage.expand", { defaultValue: "展开" })
|
||
}
|
||
>
|
||
{isExpanded ? (
|
||
<ChevronUp size={14} />
|
||
) : (
|
||
<ChevronDown size={14} />
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5 flex-shrink-0 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-opacity duration-200">
|
||
<ProviderActions
|
||
appId={appId}
|
||
isCurrent={isCurrent}
|
||
isInConfig={isInConfig}
|
||
isTesting={isTesting}
|
||
isProxyTakeover={isProxyTakeover}
|
||
isOfficialBlockedByProxy={isOfficialBlockedByProxy}
|
||
isReadOnly={isHermesReadOnly}
|
||
isOmo={isAnyOmo}
|
||
onSwitch={() => onSwitch(provider)}
|
||
onEdit={() => onEdit(provider)}
|
||
onDuplicate={() => onDuplicate(provider)}
|
||
onTest={
|
||
// 连通检测对第三方/自定义/Copilot/Codex-OAuth 供应商开放(这些正是旧的
|
||
// 真实请求探测会误报、而可达性探测能正确处理的对象)。官方供应商
|
||
// (category === "official") 一律隐藏:它们 base_url 故意留空、走客户端
|
||
// 默认/OAuth 端点,cc-switch 没有可靠的探测目标(尤其 Claude Desktop
|
||
// 官方是原生 1P 模式,根本不在请求路径上)。
|
||
onTest && provider.category !== "official"
|
||
? () => onTest(provider)
|
||
: undefined
|
||
}
|
||
onConfigureUsage={
|
||
(isOfficial && !supportsOfficialSubscription) ||
|
||
isCopilot ||
|
||
isCodexOauth ||
|
||
isXaiOauth
|
||
? undefined
|
||
: () => onConfigureUsage(provider)
|
||
}
|
||
onDelete={() => onDelete(provider)}
|
||
onRemoveFromConfig={
|
||
onRemoveFromConfig
|
||
? () => onRemoveFromConfig(provider)
|
||
: undefined
|
||
}
|
||
onDisableOmo={handleDisableAnyOmo}
|
||
onOpenTerminal={
|
||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||
}
|
||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||
isInFailoverQueue={isInFailoverQueue}
|
||
onToggleFailover={onToggleFailover}
|
||
// OpenClaw: default model
|
||
isDefaultModel={isDefaultModel}
|
||
onSetAsDefault={onSetAsDefault}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{isExpanded && hasMultiplePlans && (
|
||
<div className="mt-4 pt-4 border-t border-border-default">
|
||
<UsageFooter
|
||
provider={provider}
|
||
providerId={provider.id}
|
||
appId={appId}
|
||
usageEnabled={usageEnabled}
|
||
isCurrent={isCurrent}
|
||
isInConfig={isInConfig}
|
||
inline={false}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|