Files
CC-Switch/src/components/providers/ProviderCard.tsx
T
Jason d33d63e4df feat: display Copilot premium interactions quota on provider card
Copilot usage query API was implemented but never surfaced on the main
provider list. Add CopilotQuotaFooter component that auto-detects
github_copilot providers and displays premium interaction utilization
inline, reusing the existing TierBadge UI from SubscriptionQuotaFooter.
2026-04-09 16:49:13 +08:00

457 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { PROVIDER_TYPES } from "@/config/constants";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries";
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 {
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;
return !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 isCopilot =
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
provider.meta?.usage_script?.templateType === "github_copilot";
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
const shouldAutoQuery =
appId === "opencode" || appId === "openclaw" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0
: 0;
const { data: usage } = useUsageQuery(provider.id, appId, {
enabled: usageEnabled,
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 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 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
name={provider.name}
color={provider.iconColor}
size={20}
/>
</div>
<div className="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>
)}
{isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge
consecutiveFailures={health.consecutive_failures}
/>
)}
{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>
)}
</div>
{displayUrl && (
<button
type="button"
onClick={handleOpenWebsite}
className={cn(
"inline-flex items-center text-sm max-w-[280px]",
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="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} />
) : isOfficial ? (
<SubscriptionQuotaFooter appId={appId} inline={true} />
) : 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}
isOmo={isAnyOmo}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
onTest={
onTest && !isOfficial ? () => onTest(provider) : undefined
}
onConfigureUsage={
isOfficial ? 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>
);
}