mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
5c36ae066b
The proxy-takeover block previously fell back to the isOfficial heuristic (empty base_url / missing key) when category was absent. That misjudged custom providers whose endpoint lives in meta or whose fields are simply unfilled: their switch button got disabled, making users think the config was broken. That extra UI block was also "virtual" — the executor in useProviderActions only ever honored category === "official", so the front end blocked more than the backend would enforce. Gate the block solely on explicit category === "official", matching the executor and unifying both verdicts on a single source of truth. Also rework the blocked-state UI: - drop the red "blocked" badge for a plain disabled Enable button - move title/cursor onto a wrapper span (disabled buttons set pointer-events:none, so an on-button title/cursor never fired) - replace the account-ban warning tooltip with a lighter hint (provider.blockedByProxyHint), four locales kept in sync
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import {
|
||
BarChart3,
|
||
Check,
|
||
Copy,
|
||
Edit,
|
||
Loader2,
|
||
Minus,
|
||
Play,
|
||
Plus,
|
||
Terminal,
|
||
TestTube2,
|
||
Trash2,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { Button } from "@/components/ui/button";
|
||
import { cn } from "@/lib/utils";
|
||
import type { AppId } from "@/lib/api";
|
||
|
||
interface ProviderActionsProps {
|
||
appId?: AppId;
|
||
isCurrent: boolean;
|
||
isInConfig?: boolean;
|
||
isTesting?: boolean;
|
||
isProxyTakeover?: boolean;
|
||
isOmo?: boolean;
|
||
onSwitch: () => void;
|
||
onEdit: () => void;
|
||
onDuplicate: () => void;
|
||
onTest?: () => void;
|
||
onConfigureUsage?: () => void;
|
||
onDelete: () => void;
|
||
onRemoveFromConfig?: () => void;
|
||
onDisableOmo?: () => void;
|
||
onOpenTerminal?: () => void;
|
||
isAutoFailoverEnabled?: boolean;
|
||
isInFailoverQueue?: boolean;
|
||
onToggleFailover?: (enabled: boolean) => void;
|
||
isOfficialBlockedByProxy?: boolean;
|
||
// Hermes v12+ providers: dict overlay — edit/delete must go through Web UI
|
||
isReadOnly?: boolean;
|
||
// OpenClaw: default model
|
||
isDefaultModel?: boolean;
|
||
onSetAsDefault?: () => void;
|
||
}
|
||
|
||
// 主按钮的呈现状态。title 用于 disabled 态向用户解释为何不可点击;
|
||
// 因 Button 基类带 disabled:pointer-events-none,title 必须挂在外层非禁用
|
||
// 的 wrapper 上才会在 hover 时显示(见下方 <span> 包裹)。
|
||
interface MainButtonState {
|
||
disabled: boolean;
|
||
variant: "default" | "secondary";
|
||
className: string;
|
||
icon: JSX.Element;
|
||
text: string;
|
||
title?: string;
|
||
}
|
||
|
||
export function ProviderActions({
|
||
appId,
|
||
isCurrent,
|
||
isInConfig = false,
|
||
isTesting,
|
||
isProxyTakeover = false,
|
||
isOmo = false,
|
||
onSwitch,
|
||
onEdit,
|
||
onDuplicate,
|
||
onTest,
|
||
onConfigureUsage,
|
||
onDelete,
|
||
onRemoveFromConfig,
|
||
onDisableOmo,
|
||
onOpenTerminal,
|
||
isAutoFailoverEnabled = false,
|
||
isInFailoverQueue = false,
|
||
onToggleFailover,
|
||
isOfficialBlockedByProxy = false,
|
||
isReadOnly = false,
|
||
// OpenClaw: default model
|
||
isDefaultModel = false,
|
||
onSetAsDefault,
|
||
}: ProviderActionsProps) {
|
||
const { t } = useTranslation();
|
||
const iconButtonClass = "h-8 w-8 p-1";
|
||
|
||
// 累加模式应用(OpenCode 非 OMO / OpenClaw / Hermes)
|
||
const isAdditiveMode =
|
||
(appId === "opencode" && !isOmo) ||
|
||
appId === "openclaw" ||
|
||
appId === "hermes";
|
||
|
||
// 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移)
|
||
const isFailoverMode =
|
||
!isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||
|
||
const handleMainButtonClick = () => {
|
||
if (isOmo) {
|
||
if (isCurrent) {
|
||
onDisableOmo?.();
|
||
} else {
|
||
onSwitch();
|
||
}
|
||
} else if (isAdditiveMode) {
|
||
// 累加模式:切换配置状态(添加/移除)
|
||
if (isInConfig) {
|
||
if (onRemoveFromConfig) {
|
||
onRemoveFromConfig();
|
||
} else {
|
||
onDelete();
|
||
}
|
||
} else {
|
||
onSwitch(); // 添加到配置
|
||
}
|
||
} else if (isFailoverMode) {
|
||
onToggleFailover(!isInFailoverQueue);
|
||
} else {
|
||
onSwitch();
|
||
}
|
||
};
|
||
|
||
const getMainButtonState = (): MainButtonState => {
|
||
if (isOmo) {
|
||
if (isCurrent) {
|
||
return {
|
||
disabled: false,
|
||
variant: "secondary" as const,
|
||
className:
|
||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||
icon: <Check className="h-4 w-4" />,
|
||
text: t("provider.inUse"),
|
||
};
|
||
}
|
||
return {
|
||
disabled: false,
|
||
variant: "default" as const,
|
||
className: "",
|
||
icon: <Play className="h-4 w-4" />,
|
||
text: t("provider.enable"),
|
||
};
|
||
}
|
||
|
||
// 累加模式(OpenCode 非 OMO / OpenClaw)
|
||
if (isAdditiveMode) {
|
||
if (isInConfig) {
|
||
return {
|
||
disabled: isDefaultModel === true,
|
||
variant: "secondary" as const,
|
||
className: cn(
|
||
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
|
||
isDefaultModel && "opacity-40 cursor-not-allowed",
|
||
),
|
||
icon: <Minus className="h-4 w-4" />,
|
||
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
|
||
};
|
||
}
|
||
return {
|
||
disabled: false,
|
||
variant: "default" as const,
|
||
className:
|
||
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||
icon: <Plus className="h-4 w-4" />,
|
||
text: t("provider.addToConfig", { defaultValue: "添加" }),
|
||
};
|
||
}
|
||
|
||
if (isFailoverMode) {
|
||
if (isInFailoverQueue) {
|
||
return {
|
||
disabled: false,
|
||
variant: "secondary" as const,
|
||
className:
|
||
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
|
||
icon: <Check className="h-4 w-4" />,
|
||
text: t("failover.inQueue", { defaultValue: "已加入" }),
|
||
};
|
||
}
|
||
return {
|
||
disabled: false,
|
||
variant: "default" as const,
|
||
className:
|
||
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||
icon: <Plus className="h-4 w-4" />,
|
||
text: t("failover.addQueue", { defaultValue: "加入" }),
|
||
};
|
||
}
|
||
|
||
if (isCurrent) {
|
||
return {
|
||
disabled: true,
|
||
variant: "secondary" as const,
|
||
className:
|
||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||
icon: <Check className="h-4 w-4" />,
|
||
text: t("provider.inUse"),
|
||
};
|
||
}
|
||
|
||
if (isOfficialBlockedByProxy) {
|
||
return {
|
||
disabled: true,
|
||
variant: "default" as const,
|
||
className: "",
|
||
icon: <Play className="h-4 w-4" />,
|
||
text: t("provider.enable"),
|
||
title: t("provider.blockedByProxyHint"),
|
||
};
|
||
}
|
||
|
||
return {
|
||
disabled: false,
|
||
variant: "default" as const,
|
||
className: isProxyTakeover
|
||
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
||
: "",
|
||
icon: <Play className="h-4 w-4" />,
|
||
text: t("provider.enable"),
|
||
};
|
||
};
|
||
|
||
const buttonState = getMainButtonState();
|
||
|
||
const canDelete =
|
||
!isReadOnly && (isOmo || isAdditiveMode ? true : !isCurrent);
|
||
const readOnlyHint = t("provider.managedByHermesHint", {
|
||
defaultValue: "由 Hermes 管理,请在 Hermes Web UI 中编辑",
|
||
});
|
||
|
||
return (
|
||
<div className="flex items-center gap-1.5">
|
||
{(appId === "openclaw" || appId === "hermes") &&
|
||
isInConfig &&
|
||
onSetAsDefault &&
|
||
(() => {
|
||
const activeLabel =
|
||
appId === "hermes"
|
||
? t("provider.inUse", { defaultValue: "已在用" })
|
||
: t("provider.isDefault", { defaultValue: "当前默认" });
|
||
const inactiveLabel =
|
||
appId === "hermes"
|
||
? t("provider.enable", { defaultValue: "启用" })
|
||
: t("provider.setAsDefault", { defaultValue: "设为默认" });
|
||
return (
|
||
<Button
|
||
size="sm"
|
||
variant={isDefaultModel ? "secondary" : "default"}
|
||
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
||
disabled={isDefaultModel}
|
||
className={cn(
|
||
"w-fit px-2.5",
|
||
isDefaultModel
|
||
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
||
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||
)}
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
{isDefaultModel ? activeLabel : inactiveLabel}
|
||
</Button>
|
||
);
|
||
})()}
|
||
|
||
{/* wrapper span 承接 hover:disabled 按钮自身 pointer-events:none,
|
||
原生 title 与 cursor 都必须挂在未禁用的外层元素上才会生效 */}
|
||
<span
|
||
title={buttonState.title}
|
||
className={cn(
|
||
"inline-flex",
|
||
buttonState.disabled && "cursor-not-allowed",
|
||
)}
|
||
>
|
||
<Button
|
||
size="sm"
|
||
variant={buttonState.variant}
|
||
onClick={handleMainButtonClick}
|
||
disabled={buttonState.disabled}
|
||
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||
>
|
||
{buttonState.icon}
|
||
{buttonState.text}
|
||
</Button>
|
||
</span>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={isReadOnly ? undefined : onEdit}
|
||
disabled={isReadOnly}
|
||
title={isReadOnly ? readOnlyHint : t("common.edit")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
isReadOnly && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||
)}
|
||
>
|
||
<Edit className="h-4 w-4" />
|
||
</Button>
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={onDuplicate}
|
||
title={t("provider.duplicate")}
|
||
className={iconButtonClass}
|
||
>
|
||
<Copy className="h-4 w-4" />
|
||
</Button>
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={onTest || undefined}
|
||
disabled={isTesting}
|
||
title={t("modelTest.testProvider", "测试模型")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
!onTest && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||
)}
|
||
>
|
||
{isTesting ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<TestTube2 className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={onConfigureUsage || undefined}
|
||
title={t("provider.configureUsage")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
!onConfigureUsage &&
|
||
"opacity-40 cursor-not-allowed text-muted-foreground",
|
||
)}
|
||
>
|
||
<BarChart3 className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{onOpenTerminal && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={onOpenTerminal}
|
||
title={t("provider.openTerminal", "打开终端")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
"hover:text-emerald-600 dark:hover:text-emerald-400",
|
||
)}
|
||
>
|
||
<Terminal className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={canDelete ? onDelete : undefined}
|
||
title={isReadOnly ? readOnlyHint : t("common.delete")}
|
||
className={cn(
|
||
iconButtonClass,
|
||
canDelete && "hover:text-red-500 dark:hover:text-red-400",
|
||
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground",
|
||
)}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|