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>