/** * 全局出站代理设置组件 * * 提供配置全局代理的输入界面,支持用户名密码认证。 */ import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react"; import { useGlobalProxyUrl, useSetGlobalProxyUrl, useTestProxy, useScanProxies, type DetectedProxy, } from "@/hooks/useGlobalProxy"; /** 从完整 URL 提取认证信息 */ function extractAuth(url: string): { baseUrl: string; username: string; password: string; } { if (!url.trim()) return { baseUrl: "", username: "", password: "" }; try { const parsed = new URL(url); const username = decodeURIComponent(parsed.username || ""); const password = decodeURIComponent(parsed.password || ""); // 移除认证信息,获取基础 URL parsed.username = ""; parsed.password = ""; return { baseUrl: parsed.toString(), username, password }; } catch { return { baseUrl: url, username: "", password: "" }; } } /** 将认证信息合并到 URL */ function mergeAuth( baseUrl: string, username: string, password: string, ): string { if (!baseUrl.trim()) return ""; if (!username.trim()) return baseUrl; try { const parsed = new URL(baseUrl); // URL 对象的 username/password setter 会自动进行 percent-encoding // 不要使用 encodeURIComponent,否则会导致双重编码 parsed.username = username.trim(); if (password) { parsed.password = password; } return parsed.toString(); } catch { // URL 解析失败,尝试手动插入(此时需要手动编码) const match = baseUrl.match(/^(\w+:\/\/)(.+)$/); if (match) { const auth = password ? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@` : `${encodeURIComponent(username.trim())}@`; return `${match[1]}${auth}${match[2]}`; } return baseUrl; } } export function GlobalProxySettings() { const { t } = useTranslation(); const { data: savedUrl, isLoading } = useGlobalProxyUrl(); const setMutation = useSetGlobalProxyUrl(); const testMutation = useTestProxy(); const scanMutation = useScanProxies(); const [url, setUrl] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [dirty, setDirty] = useState(false); const [detected, setDetected] = useState([]); // 计算完整 URL(含认证信息) const fullUrl = useMemo( () => mergeAuth(url, username, password), [url, username, password], ); // 同步远程配置 useEffect(() => { if (savedUrl !== undefined) { const { baseUrl, username: u, password: p } = extractAuth(savedUrl || ""); setUrl(baseUrl); setUsername(u); setPassword(p); setDirty(false); } }, [savedUrl]); const handleSave = async () => { await setMutation.mutateAsync(fullUrl); setDirty(false); }; const handleTest = async () => { if (fullUrl) { await testMutation.mutateAsync(fullUrl); } }; const handleScan = async () => { const result = await scanMutation.mutateAsync(); setDetected(result); }; const handleSelect = (proxyUrl: string) => { const { baseUrl, username: u, password: p } = extractAuth(proxyUrl); setUrl(baseUrl); setUsername(u); setPassword(p); setDirty(true); setDetected([]); }; const handleClear = () => { setUrl(""); setUsername(""); setPassword(""); setDirty(true); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && dirty && !setMutation.isPending) { handleSave(); } }; // 只在首次加载且无数据时显示加载状态 if (isLoading && savedUrl === undefined) { return (
); } return (
{/* 描述 */}

{t("settings.globalProxy.hint")}

{/* 代理地址输入框和按钮 */}
{ setUrl(e.target.value); setDirty(true); }} onKeyDown={handleKeyDown} className="font-mono text-sm flex-1" />
{/* 认证信息:用户名 + 密码(可选) */}
{ setUsername(e.target.value); setDirty(true); }} onKeyDown={handleKeyDown} className="font-mono text-sm flex-1" />
{ setPassword(e.target.value); setDirty(true); }} onKeyDown={handleKeyDown} className="font-mono text-sm pr-10" />
{/* 扫描结果 */} {detected.length > 0 && (
{detected.map((p) => ( ))}
)}
); }