fix(providers): only block explicit official providers under proxy takeover

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
This commit is contained in:
Jason
2026-06-07 17:10:40 +08:00
parent f59fab6c24
commit 5c36ae066b
6 changed files with 56 additions and 26 deletions
+43 -21
View File
@@ -7,7 +7,6 @@ import {
Minus,
Play,
Plus,
ShieldAlert,
Terminal,
TestTube2,
Trash2,
@@ -45,6 +44,18 @@ interface ProviderActionsProps {
onSetAsDefault?: () => void;
}
// 主按钮的呈现状态。title 用于 disabled 态向用户解释为何不可点击;
// 因 Button 基类带 disabled:pointer-events-nonetitle 必须挂在外层非禁用
// 的 wrapper 上才会在 hover 时显示(见下方 <span> 包裹)。
interface MainButtonState {
disabled: boolean;
variant: "default" | "secondary";
className: string;
icon: JSX.Element;
text: string;
title?: string;
}
export function ProviderActions({
appId,
isCurrent,
@@ -108,7 +119,7 @@ export function ProviderActions({
}
};
const getMainButtonState = () => {
const getMainButtonState = (): MainButtonState => {
if (isOmo) {
if (isCurrent) {
return {
@@ -174,16 +185,6 @@ export function ProviderActions({
};
}
if (isOfficialBlockedByProxy) {
return {
disabled: true,
variant: "secondary" as const,
className: "opacity-40 cursor-not-allowed",
icon: <ShieldAlert className="h-4 w-4" />,
text: t("provider.blockedByProxy", { defaultValue: "已拦截" }),
};
}
if (isCurrent) {
return {
disabled: true,
@@ -195,6 +196,17 @@ export function ProviderActions({
};
}
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,
@@ -247,16 +259,26 @@ export function ProviderActions({
);
})()}
<Button
size="sm"
variant={buttonState.variant}
onClick={handleMainButtonClick}
disabled={buttonState.disabled}
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
{/* wrapper span 承接 hoverdisabled 按钮自身 pointer-events:none
原生 title 与 cursor 都必须挂在未禁用的外层元素上才会生效 */}
<span
title={buttonState.title}
className={cn(
"inline-flex",
buttonState.disabled && "cursor-not-allowed",
)}
>
{buttonState.icon}
{buttonState.text}
</Button>
<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
+9 -1
View File
@@ -199,8 +199,16 @@ export function ProviderCard({
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 isOfficialBlockedByProxy =
isProxyTakeover && (provider.category === "official" || isOfficial);
isProxyTakeover && provider.category === "official";
const isCopilot =
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
provider.meta?.usage_script?.templateType === "github_copilot";
+1 -1
View File
@@ -109,7 +109,7 @@
"currentlyUsing": "Currently Using",
"enable": "Enable",
"inUse": "In Use",
"blockedByProxy": "Blocked",
"blockedByProxyHint": "Can't switch to an official provider while proxy takeover is active",
"editProvider": "Edit Provider",
"editProviderHint": "Configuration will be applied to the current provider immediately after update.",
"deleteProvider": "Delete Provider",
+1 -1
View File
@@ -109,7 +109,7 @@
"currentlyUsing": "現在使用中",
"enable": "有効化",
"inUse": "使用中",
"blockedByProxy": "ブロック",
"blockedByProxyHint": "プロキシ引き継ぎモードでは公式プロバイダーに切り替えできません",
"editProvider": "プロバイダーを編集",
"editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。",
"deleteProvider": "プロバイダーを削除",
+1 -1
View File
@@ -109,7 +109,7 @@
"currentlyUsing": "目前使用",
"enable": "啟用",
"inUse": "使用中",
"blockedByProxy": "已攔截",
"blockedByProxyHint": "代理接管模式下無法切換至官方供應商",
"editProvider": "編輯供應商",
"editProviderHint": "更新設定後將立即套用至目前供應商。",
"deleteProvider": "刪除供應商",
+1 -1
View File
@@ -109,7 +109,7 @@
"currentlyUsing": "当前使用",
"enable": "启用",
"inUse": "使用中",
"blockedByProxy": "已拦截",
"blockedByProxyHint": "代理接管模式下不可切换到官方供应商",
"editProvider": "编辑供应商",
"editProviderHint": "更新配置后将立即应用到当前供应商。",
"deleteProvider": "删除供应商",