refactor: move proxy toggle into panel and surface app takeover options

Move the proxy on/off switch from the accordion header into the panel
content area, placing it right above the app takeover section. This
ensures users see the takeover options immediately after enabling the
proxy, preventing the common pitfall of running the proxy without
actually taking over any app.

- Simplify accordion trigger to standard style with Badge only
- Add AnimatePresence animation for takeover section reveal
- Remove duplicate takeover switches from running info card
- Update stoppedDescription i18n to reference "above toggle"
- Add proxy.takeover.hint key in zh/en/ja
This commit is contained in:
Jason
2026-02-28 09:13:32 +08:00
parent f8c1f1736e
commit d5e4e8d133
5 changed files with 153 additions and 86 deletions
+113 -44
View File
@@ -7,11 +7,14 @@ import {
ListOrdered,
Save,
Loader2,
Zap,
Power,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ToggleRow } from "@/components/ui/toggle-row";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { toast } from "sonner";
import { useFailoverQueue } from "@/lib/query/failover";
@@ -25,8 +28,21 @@ import {
} from "@/lib/query/proxy";
import type { ProxyStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";
export function ProxyPanel() {
interface ProxyPanelProps {
enableLocalProxy: boolean;
onEnableLocalProxyChange: (checked: boolean) => void;
onToggleProxy: (checked: boolean) => Promise<void>;
isProxyPending: boolean;
}
export function ProxyPanel({
enableLocalProxy,
onEnableLocalProxyChange,
onToggleProxy,
isProxyPending,
}: ProxyPanelProps) {
const { t } = useTranslation();
const { status, isRunning } = useProxyStatus();
@@ -183,9 +199,98 @@ export function ProxyPanel() {
return (
<>
<section className="space-y-6">
<section className="space-y-4">
{/* [1] Enable proxy button on main page — always visible */}
<ToggleRow
icon={<Zap className="h-4 w-4 text-green-500" />}
title={t("settings.advanced.proxy.enableFeature")}
description={t("settings.advanced.proxy.enableFeatureDescription")}
checked={enableLocalProxy}
onCheckedChange={onEnableLocalProxyChange}
/>
{/* [2] Proxy service toggle — always visible */}
<div className="flex items-center justify-between rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
<Power className="h-4 w-4 text-green-500" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{t("proxyConfig.proxyEnabled", {
defaultValue: "代理服务",
})}
</p>
<p className="text-xs text-muted-foreground">
{isRunning
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</p>
</div>
</div>
<Switch
checked={isRunning}
onCheckedChange={onToggleProxy}
disabled={isProxyPending}
/>
</div>
{/* [3] App takeover switches — animated, visible only when proxy is running */}
<AnimatePresence>
{isRunning && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="rounded-xl border-2 border-primary/20 bg-primary/5 p-4 space-y-3">
<p className="text-xs font-medium text-primary">
{t("proxyConfig.appTakeover", {
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t("proxy.takeover.hint", {
defaultValue:
"选择要接管的应用,启用后该应用的请求将通过本地代理转发",
})}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Running state: service info + stats */}
{isRunning && status ? (
<div className="space-y-6">
{/* [4] Running info: address + current provider */}
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<p className="text-xs text-muted-foreground mb-2">
@@ -263,41 +368,7 @@ export function ProxyPanel() {
)}
</div>
{/* 应用接管开关 */}
<div className="pt-3 border-t border-border space-y-3">
<p className="text-xs text-muted-foreground">
{t("proxyConfig.appTakeover", {
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
</div>
</div>
{/* 日志记录开关 */}
{/* [5] Logging toggle */}
<div className="pt-3 border-t border-border">
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
<div className="space-y-0.5">
@@ -320,7 +391,7 @@ export function ProxyPanel() {
</div>
</div>
{/* 供应商队列 - 按应用类型分组展示 */}
{/* [6] Provider queues */}
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
geminiQueue.length > 0) && (
@@ -332,7 +403,6 @@ export function ProxyPanel() {
</p>
</div>
{/* Claude 队列 */}
{claudeQueue.length > 0 && (
<ProviderQueueGroup
appType="claude"
@@ -345,7 +415,6 @@ export function ProxyPanel() {
/>
)}
{/* Codex 队列 */}
{codexQueue.length > 0 && (
<ProviderQueueGroup
appType="codex"
@@ -358,7 +427,6 @@ export function ProxyPanel() {
/>
)}
{/* Gemini 队列 */}
{geminiQueue.length > 0 && (
<ProviderQueueGroup
appType="gemini"
@@ -374,6 +442,7 @@ export function ProxyPanel() {
)}
</div>
{/* [7] Stats cards */}
<div className="grid gap-3 md:grid-cols-4">
<StatCard
icon={<Activity className="h-4 w-4" />}
@@ -408,7 +477,7 @@ export function ProxyPanel() {
</div>
) : (
<div className="space-y-6">
{/* 基础设置 - 监听地址/端口 */}
{/* [8] Basic settings — address/port (only when stopped) */}
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<h4 className="text-sm font-semibold">
@@ -496,7 +565,7 @@ export function ProxyPanel() {
</div>
</div>
{/* 代理服务已停止提示 */}
{/* Stopped hint */}
<div className="text-center py-6 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
@@ -508,7 +577,7 @@ export function ProxyPanel() {
</p>
<p className="text-sm text-muted-foreground">
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
defaultValue: "使用上方开关即可启动服务",
})}
</p>
</div>
+19 -39
View File
@@ -1,6 +1,5 @@
import { useState } from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { Server, Activity, ChevronDown, Zap, Globe } from "lucide-react";
import { Server, Activity, Zap, Globe } from "lucide-react";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import {
@@ -10,8 +9,6 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
import { ToggleRow } from "@/components/ui/toggle-row";
import { Badge } from "@/components/ui/badge";
import { ProxyPanel } from "@/components/proxy";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
@@ -76,28 +73,22 @@ export function ProxyTabContent({
{/* Local Proxy */}
<AccordionItem
value="proxy"
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.proxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.proxy.description")}
</p>
</div>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.proxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.proxy.description")}
</p>
</div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<div className="flex items-center gap-4 pl-4">
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
className="gap-1.5 h-6 ml-auto mr-2"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
@@ -106,28 +97,17 @@ export function ProxyTabContent({
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
/>
</div>
</AccordionPrimitive.Header>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ToggleRow
icon={<Zap className="h-4 w-4 text-green-500" />}
title={t("settings.advanced.proxy.enableFeature")}
description={t(
"settings.advanced.proxy.enableFeatureDescription",
)}
checked={settings?.enableLocalProxy ?? false}
onCheckedChange={(checked) =>
<ProxyPanel
enableLocalProxy={settings?.enableLocalProxy ?? false}
onEnableLocalProxyChange={(checked) =>
onAutoSave({ enableLocalProxy: checked })
}
onToggleProxy={handleToggleProxy}
isProxyPending={isProxyPending}
/>
<div className="mt-4">
<ProxyPanel />
</div>
</AccordionContent>
</AccordionItem>
+7 -1
View File
@@ -1582,7 +1582,7 @@
"currentProvider": "Current Provider:",
"waitingFirstRequest": "Current Provider: Waiting for first request...",
"stoppedTitle": "Proxy Service Stopped",
"stoppedDescription": "Use the toggle in the top right to start the service",
"stoppedDescription": "Use the toggle above to start the service",
"openSettings": "Configure Proxy Service",
"stats": {
"activeConnections": "Active Connections",
@@ -1673,6 +1673,12 @@
"restartRequired": "Restart proxy service for address or port changes to take effect"
},
"switchFailed": "Switch failed: {{error}}",
"takeover": {
"hint": "Select apps to take over — once enabled, requests from that app will be routed through the local proxy",
"enabled": "{{app}} takeover enabled",
"disabled": "{{app}} takeover disabled",
"failed": "Failed to toggle takeover"
},
"failover": {
"proxyRequired": "Proxy service must be started to configure failover",
"autoSwitch": "Auto Failover",
+7 -1
View File
@@ -1582,7 +1582,7 @@
"currentProvider": "現在のプロバイダー:",
"waitingFirstRequest": "現在のプロバイダー: 最初のリクエスト待ち...",
"stoppedTitle": "プロキシサービス停止中",
"stoppedDescription": "上のトグルでサービスを開始できます",
"stoppedDescription": "上のトグルでサービスを開始できます",
"openSettings": "プロキシサービスを設定",
"stats": {
"activeConnections": "アクティブ接続",
@@ -1673,6 +1673,12 @@
"restartRequired": "アドレスまたはポートの変更を反映するにはプロキシサービスの再起動が必要です"
},
"switchFailed": "切り替えに失敗しました: {{error}}",
"takeover": {
"hint": "テイクオーバーするアプリを選択します。有効にすると、そのアプリのリクエストはローカルプロキシ経由で転送されます",
"enabled": "{{app}} テイクオーバー有効",
"disabled": "{{app}} テイクオーバー無効",
"failed": "テイクオーバーの切り替えに失敗しました"
},
"failover": {
"proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります",
"autoSwitch": "自動フェイルオーバー",
+7 -1
View File
@@ -1582,7 +1582,7 @@
"currentProvider": "当前 Provider",
"waitingFirstRequest": "当前 Provider:等待首次请求…",
"stoppedTitle": "代理服务已停止",
"stoppedDescription": "使用右上角开关即可启动服务",
"stoppedDescription": "使用上方开关即可启动服务",
"openSettings": "配置代理服务",
"stats": {
"activeConnections": "活跃连接",
@@ -1673,6 +1673,12 @@
"restartRequired": "修改地址或端口后需要重启代理服务才能生效"
},
"switchFailed": "切换失败: {{error}}",
"takeover": {
"hint": "选择要接管的应用,启用后该应用的请求将通过本地代理转发",
"enabled": "{{app}} 接管已启用",
"disabled": "{{app}} 接管已关闭",
"failed": "切换接管状态失败"
},
"failover": {
"proxyRequired": "需要先启动代理服务才能配置故障转移",
"autoSwitch": "自动故障转移",