mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
d33d63e4df
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.
457 lines
16 KiB
TypeScript
457 lines
16 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 { 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>
|
||
);
|
||
}
|