diff --git a/src/components/settings/GlobalProxySettings.tsx b/src/components/settings/GlobalProxySettings.tsx index 6d0bd0bb1..5d0caa24f 100644 --- a/src/components/settings/GlobalProxySettings.tsx +++ b/src/components/settings/GlobalProxySettings.tsx @@ -1,14 +1,14 @@ /** * 全局出站代理设置组件 * - * 提供配置全局代理的输入界面。 + * 提供配置全局代理的输入界面,支持用户名密码认证。 */ -import { useState, useEffect } from "react"; +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 } from "lucide-react"; +import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react"; import { useGlobalProxyUrl, useSetGlobalProxyUrl, @@ -17,6 +17,56 @@ import { 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); + parsed.username = encodeURIComponent(username.trim()); + if (password) { + parsed.password = encodeURIComponent(password); + } + return parsed.toString(); + } catch { + // URL 解析失败,尝试手动插入 + const match = baseUrl.match(/^(\w+:\/\/)(.+)$/); + if (match) { + const auth = password + ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` + : `${encodeURIComponent(username)}@`; + return `${match[1]}${auth}${match[2]}`; + } + return baseUrl; + } +} + export function GlobalProxySettings() { const { t } = useTranslation(); const { data: savedUrl, isLoading } = useGlobalProxyUrl(); @@ -25,25 +75,37 @@ export function GlobalProxySettings() { 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) { - setUrl(savedUrl || ""); + const { baseUrl, username: u, password: p } = extractAuth(savedUrl || ""); + setUrl(baseUrl); + setUsername(u); + setPassword(p); setDirty(false); } }, [savedUrl]); const handleSave = async () => { - await setMutation.mutateAsync(url.trim()); + await setMutation.mutateAsync(fullUrl); setDirty(false); }; const handleTest = async () => { - if (url.trim()) { - await testMutation.mutateAsync(url.trim()); + if (fullUrl) { + await testMutation.mutateAsync(fullUrl); } }; @@ -53,11 +115,21 @@ export function GlobalProxySettings() { }; const handleSelect = (proxyUrl: string) => { - setUrl(proxyUrl); + 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(); @@ -80,7 +152,7 @@ export function GlobalProxySettings() { {t("settings.globalProxy.hint")}

- {/* 输入框和按钮 */} + {/* 代理地址输入框和按钮 */}
@@ -118,6 +190,15 @@ export function GlobalProxySettings() { )} +
+ {/* 认证信息:用户名 + 密码(可选) */} +
+ { + 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 && (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 974f0f59b..8df92f7b0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -278,9 +278,12 @@ "saving": "Saving...", "globalProxy": { "label": "Global Proxy", - "hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection. Supports HTTP and SOCKS5.", + "hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.", + "username": "Username (optional)", + "password": "Password (optional)", "test": "Test Connection", "scan": "Scan Local Proxies", + "clear": "Clear", "scanFailed": "Scan failed: {{error}}", "saved": "Proxy settings saved", "saveFailed": "Save failed: {{error}}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0ec72b728..fc269082e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -278,9 +278,12 @@ "saving": "保存中...", "globalProxy": { "label": "グローバルプロキシ", - "hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。HTTP と SOCKS5 をサポート。", + "hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。", + "username": "ユーザー名(任意)", + "password": "パスワード(任意)", "test": "接続テスト", "scan": "ローカルプロキシをスキャン", + "clear": "クリア", "scanFailed": "スキャンに失敗しました: {{error}}", "saved": "プロキシ設定を保存しました", "saveFailed": "保存に失敗しました: {{error}}", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 39c177793..de9d1f81a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -278,9 +278,12 @@ "saving": "正在保存...", "globalProxy": { "label": "全局代理", - "hint": "代理所有请求(API、Skills 下载等)。留空表示直连。支持 HTTP 和 SOCKS5 代理。", + "hint": "代理所有请求(API、Skills 下载等)。留空表示直连。", + "username": "用户名(可选)", + "password": "密码(可选)", "test": "测试连接", "scan": "扫描本地代理", + "clear": "清除", "scanFailed": "扫描失败:{{error}}", "saved": "代理设置已保存", "saveFailed": "保存失败:{{error}}",