diff --git a/backend/internal/proxy/parser.go b/backend/internal/proxy/parser.go index 8950e60f..6c06ba9c 100644 --- a/backend/internal/proxy/parser.go +++ b/backend/internal/proxy/parser.go @@ -10,6 +10,81 @@ import ( "gopkg.in/yaml.v3" ) +const chainSocks5Prefix = "chain+socks5://" + +type chainSocks5Hop struct { + Protocol string `json:"protocol"` + Server string `json:"server"` + Port int `json:"port"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + +type chainSocks5Config struct { + LocalPort int `json:"localPort,omitempty"` + First chainSocks5Hop `json:"first"` + Second chainSocks5Hop `json:"second"` +} + +func IsChainSocks5Proxy(src string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(src)), chainSocks5Prefix) +} + +func ParseChainSocks5Config(src string) (*chainSocks5Config, error) { + raw := strings.TrimSpace(src) + if !IsChainSocks5Proxy(raw) { + return nil, fmt.Errorf("不是链式代理配置") + } + encoded := raw[len(chainSocks5Prefix):] + if strings.TrimSpace(encoded) == "" { + return nil, fmt.Errorf("链式代理配置为空") + } + + decoded, err := url.QueryUnescape(encoded) + if err != nil { + return nil, fmt.Errorf("链式代理配置解码失败: %w", err) + } + + var cfg chainSocks5Config + if err := json.Unmarshal([]byte(decoded), &cfg); err != nil { + return nil, fmt.Errorf("链式代理配置 JSON 解析失败: %w", err) + } + + if err := validateChainSocks5Hop("第一层", cfg.First); err != nil { + return nil, err + } + if err := validateChainSocks5Hop("第二层", cfg.Second); err != nil { + return nil, err + } + if cfg.LocalPort < 0 || cfg.LocalPort > 65535 { + return nil, fmt.Errorf("本地监听端口必须在 1-65535 之间") + } + if cfg.First.Protocol == "" { + cfg.First.Protocol = "socks5" + } + if cfg.Second.Protocol == "" { + cfg.Second.Protocol = "socks5" + } + return &cfg, nil +} + +func validateChainSocks5Hop(label string, hop chainSocks5Hop) error { + if strings.TrimSpace(hop.Server) == "" { + return fmt.Errorf("%s代理地址不能为空", label) + } + if hop.Port < 1 || hop.Port > 65535 { + return fmt.Errorf("%s代理端口必须在 1-65535 之间", label) + } + protocol := strings.ToLower(strings.TrimSpace(hop.Protocol)) + if protocol != "" && protocol != "socks5" { + return fmt.Errorf("%s协议仅支持 socks5", label) + } + if strings.TrimSpace(hop.Password) != "" && strings.TrimSpace(hop.Username) == "" { + return fmt.Errorf("%s填写密码时请同时填写账号", label) + } + return nil +} + // ParseProxyNode 解析代理节点 func ParseProxyNode(node string) (string, map[string]interface{}, error) { src := strings.TrimSpace(node) diff --git a/backend/internal/proxy/speedtest.go b/backend/internal/proxy/speedtest.go index d63951f2..342eeef3 100644 --- a/backend/internal/proxy/speedtest.go +++ b/backend/internal/proxy/speedtest.go @@ -74,14 +74,33 @@ func SpeedTest( testURL = cfg.URLs[0] } + resolvedSrc := src + if IsChainSocks5Proxy(src) { + if xrayMgr == nil { + log.Warn("链式代理测速缺少 Xray 管理器,降级到 TCP ping", + logger.F("proxy_id", proxyId), + ) + return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + } + bridgeSocksURL, bridgeErr := xrayMgr.EnsureBridge(src, proxies, proxyId) + if bridgeErr != nil { + log.Warn("链式代理桥接失败,降级到 TCP ping", + logger.F("proxy_id", proxyId), + logger.F("error", bridgeErr.Error()), + ) + return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + } + resolvedSrc = strings.TrimSpace(bridgeSocksURL) + } + // 将代理配置转换为 mihomo mapping - mapping, err := proxyConfigToMapping(src) + mapping, err := proxyConfigToMapping(resolvedSrc) if err != nil { log.Warn("代理配置解析失败,降级到 TCP ping", logger.F("proxy_id", proxyId), logger.F("error", err.Error()), ) - return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log) } // 使用 mihomo adapter.ParseProxy 创建代理实例 @@ -92,7 +111,7 @@ func SpeedTest( logger.F("error", err.Error()), logger.F("type", mapping["type"]), ) - return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log) } // unified-delay 测速:分离连接建立和 HTTP 往返计时 diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go index a542611b..ab09dc50 100644 --- a/backend/internal/proxy/xray.go +++ b/backend/internal/proxy/xray.go @@ -82,6 +82,13 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") { return true, "" } + if IsChainSocks5Proxy(src) { + if _, err := ParseChainSocks5Config(src); err != nil { + return false, fmt.Sprintf("链式代理配置解析失败: %v", err) + } + return true, "" + } + // hysteria2/tuic 通过 sing-box 支持,先做可解析性校验 if IsSingBoxProtocol(src) { if _, err := BuildSingBoxOutbound(src); err != nil { @@ -126,6 +133,10 @@ func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId s if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") { return false } + if IsChainSocks5Proxy(src) { + return true + } + // Xray 支持的协议 if strings.HasPrefix(l, "vmess://") || strings.HasPrefix(l, "vless://") || strings.HasPrefix(l, "trojan://") || strings.HasPrefix(l, "ss://") { return true @@ -211,17 +222,53 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP return "", "", fmt.Errorf("未找到代理节点") } src = normalizeNodeScheme(src) - standardProxy, outbound, err := ParseProxyNode(src) - if err != nil { - log.Error("节点解析失败", logger.F("error", err)) - return "", "", err - } - if standardProxy != "" { - return standardProxy, "", nil - } - if outbound == nil { - return "", "", fmt.Errorf("节点解析失败") + + var ( + outbounds []interface{} + routes []interface{} + preferredPort int + ) + + if IsChainSocks5Proxy(src) { + chainCfg, err := ParseChainSocks5Config(src) + if err != nil { + log.Error("链式节点解析失败", logger.F("error", err)) + return "", "", err + } + outbounds = []interface{}{ + chainSocks5Outbound(chainCfg.First, "first-hop", ""), + chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop"), + } + routes = []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "second-hop", + }, + } + preferredPort = chainCfg.LocalPort + } else { + standardProxy, outbound, err := ParseProxyNode(src) + if err != nil { + log.Error("节点解析失败", logger.F("error", err)) + return "", "", err + } + if standardProxy != "" { + return standardProxy, "", nil + } + if outbound == nil { + return "", "", fmt.Errorf("节点解析失败") + } + outbounds = []interface{}{outbound} + routes = []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "proxy-out", + }, + } } + key := computeNodeKey(src + "\x00" + dnsServers) if socksURL, reused := m.tryReuseBridge(key, pin); reused { @@ -234,17 +281,25 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP log.Error("xray 不可用", logger.F("error", err)) return "", "", err } - // 最多重试 3 次,解决端口分配后被抢占的 TOCTOU 竞争问题 - const maxLaunchRetries = 3 + maxLaunchRetries := 3 + if preferredPort > 0 { + maxLaunchRetries = 1 + } var lastErr error for attempt := 1; attempt <= maxLaunchRetries; attempt++ { - port, err := nextAvailablePort() - if err != nil { - log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt)) - lastErr = err - continue + var port int + if preferredPort > 0 { + port = preferredPort + } else { + port, err = nextAvailablePort() + if err != nil { + log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt)) + lastErr = err + continue + } } - cfgPath, err := m.buildRuntimeConfig(key, outbound, port, dnsServers) + + cfgPath, err := m.buildRuntimeConfigWithRoute(key, outbounds, routes, port, dnsServers) if err != nil { log.Error("xray 配置生成失败", logger.F("error", err)) return "", "", err @@ -279,7 +334,6 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP if stderrFile != nil { stderrFile.Close() } - // 优先读 stderr,再读 xray-error.log if stderrContent, readErr := os.ReadFile(stderrPath); readErr == nil && len(stderrContent) > 0 { log.Error("xray stderr", logger.F("output", string(stderrContent))) } else { @@ -295,7 +349,6 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP bridge.LastError = err.Error() log.Error("xray 端口不可用,重试", logger.F("key", key), logger.F("error", err), logger.F("port", port), logger.F("attempt", attempt)) lastErr = err - // 等待一下再重试,给 OS 时间回收端口 time.Sleep(200 * time.Millisecond) continue } @@ -654,6 +707,102 @@ func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interfa return cfgPath, nil } +func chainSocks5Outbound(hop chainSocks5Hop, tag string, nextTag string) map[string]interface{} { + user := map[string]interface{}{} + if strings.TrimSpace(hop.Username) != "" { + user["user"] = hop.Username + if strings.TrimSpace(hop.Password) != "" { + user["pass"] = hop.Password + } + } + servers := []interface{}{ + map[string]interface{}{ + "address": hop.Server, + "port": hop.Port, + "users": []interface{}{user}, + }, + } + if len(user) == 0 { + servers = []interface{}{ + map[string]interface{}{ + "address": hop.Server, + "port": hop.Port, + }, + } + } + + outbound := map[string]interface{}{ + "protocol": "socks", + "tag": tag, + "settings": map[string]interface{}{ + "servers": servers, + }, + } + if strings.TrimSpace(nextTag) != "" { + outbound["proxySettings"] = map[string]interface{}{ + "tag": nextTag, + } + } + return outbound +} + +func (m *XrayManager) buildRuntimeConfigWithRoute( + key string, + outbounds []interface{}, + rules []interface{}, + port int, + dnsServers string, +) (string, error) { + baseDir := m.resolveWorkdir(key) + if err := os.MkdirAll(baseDir, 0755); err != nil { + return "", err + } + cfgPath := filepath.Join(baseDir, "xray-config.json") + cfg := map[string]interface{}{ + "log": map[string]interface{}{ + "loglevel": "info", + "error": filepath.Join(baseDir, "xray-error.log"), + }, + "inbounds": []interface{}{ + map[string]interface{}{ + "tag": "socks-in", + "port": port, + "listen": "127.0.0.1", + "protocol": "socks", + "settings": map[string]interface{}{ + "udp": true, + }, + "sniffing": map[string]interface{}{ + "enabled": false, + }, + }, + }, + "outbounds": append(outbounds, + map[string]interface{}{ + "protocol": "direct", + "tag": "direct", + }, + map[string]interface{}{ + "protocol": "blackhole", + "tag": "block", + }, + ), + "routing": map[string]interface{}{ + "rules": rules, + }, + } + if dnsCfg := parseDnsConfig(dnsServers); dnsCfg != nil { + cfg["dns"] = dnsCfg + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + return "", err + } + return cfgPath, nil +} func (m *XrayManager) resolveWorkdir(key string) string { root := strings.TrimSpace(m.Config.Browser.UserDataRoot) if root == "" { diff --git a/frontend/src/modules/browser/components/GroupSelector.tsx b/frontend/src/modules/browser/components/GroupSelector.tsx index 77fc30ef..e0635e96 100644 --- a/frontend/src/modules/browser/components/GroupSelector.tsx +++ b/frontend/src/modules/browser/components/GroupSelector.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { Select } from '../../../shared/components' import type { BrowserGroup } from '../types' interface GroupSelectorProps { @@ -15,14 +16,6 @@ interface FlatGroup extends BrowserGroup { // 将分组列表扁平化并计算层级 function flattenGroups(groups: BrowserGroup[]): FlatGroup[] { - const map = new Map() - groups.forEach(g => map.set(g.groupId, g)) - - const getLevel = (g: BrowserGroup): number => { - if (!g.parentId || !map.has(g.parentId)) return 0 - return 1 + getLevel(map.get(g.parentId)!) - } - const result: FlatGroup[] = [] const addChildren = (parentId: string, level: number) => { groups @@ -42,19 +35,23 @@ function flattenGroups(groups: BrowserGroup[]): FlatGroup[] { export function GroupSelector({ groups, value, onChange, placeholder = '选择分组', className = '' }: GroupSelectorProps) { const flatGroups = useMemo(() => flattenGroups(groups), [groups]) + const options = useMemo( + () => [ + { value: '', label: placeholder }, + ...flatGroups.map(g => ({ + value: g.groupId, + label: `${' '.repeat(g.level)}${g.groupName}`, + })), + ], + [flatGroups, placeholder] + ) return ( - + options={options} + /> ) } diff --git a/frontend/src/modules/browser/components/ProxyImportModal.tsx b/frontend/src/modules/browser/components/ProxyImportModal.tsx new file mode 100644 index 00000000..97fd8e05 --- /dev/null +++ b/frontend/src/modules/browser/components/ProxyImportModal.tsx @@ -0,0 +1,1035 @@ +import { useEffect, useMemo, useState } from 'react' +import yaml from 'js-yaml' +import { Button, FormItem, Input, Modal, Select, Table, Textarea, toast } from '../../../shared/components' +import type { TableColumn } from '../../../shared/components/Table' +import type { BrowserProxy } from '../types' +import { fetchClashImportFromURL, saveBrowserProxies } from '../api' + +interface ProxyImportModalProps { + open: boolean + onClose: () => void + existingProxies: BrowserProxy[] + groups: string[] + globalAutoRefreshEnabled?: boolean + globalRefreshIntervalM?: number + onImported?: (newProxies: BrowserProxy[]) => void | Promise +} + +interface ClashProxy { + name: string + type: string + server: string + port: number + [key: string]: any +} + +type ProxyImportMode = 'clash' | 'direct' | 'chain' + +interface DirectImportForm { + proxyName: string + protocol: 'http' | 'https' | 'socks5' + server: string + port: string + username: string + password: string +} + +interface ChainHopForm { + server: string + port: string + username: string + password: string +} + +interface ChainImportForm { + proxyName: string + localPort: string + first: ChainHopForm + second: ChainHopForm +} + +const DIRECT_PROXY_PROTOCOL_OPTIONS = [ + { value: 'http', label: 'HTTP' }, + { value: 'https', label: 'HTTPS' }, + { value: 'socks5', label: 'SOCKS5' }, +] as const + +const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = { + proxyName: '', + protocol: 'http', + server: '', + port: '', + username: '', + password: '', +} + +const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = { + proxyName: '', + localPort: '', + first: { + server: '', + port: '', + username: '', + password: '', + }, + second: { + server: '', + port: '', + username: '', + password: '', + }, +} + +interface ImportCandidate { + proxyName: string + proxyConfig: string +} + +interface ProxyDisplayInfo { + proxyId: string + proxyName: string + proxyConfig: string + groupName: string + type: string + server: string + port: number +} + +const CHAIN_SOCKS5_PREFIX = 'chain+socks5://' + +interface ChainSocks5HopConfig { + protocol: 'socks5' + server: string + port: number + username?: string + password?: string +} + +interface ChainSocks5Config { + localPort?: number + first: ChainSocks5HopConfig + second: ChainSocks5HopConfig +} + +function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null { + const cfg = proxyConfig.trim() + if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) { + return null + } + const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length) + if (!encoded) { + return null + } + + const normalizeHop = (raw: unknown): ChainSocks5HopConfig | null => { + if (!raw || typeof raw !== 'object') return null + const hop = raw as Record + const protocol = String(hop.protocol || '').trim().toLowerCase() + if (protocol && protocol !== 'socks5') return null + + const server = String(hop.server || '').trim() + if (!server) return null + + const portVal = Number(hop.port || 0) + if (!Number.isInteger(portVal) || portVal < 1 || portVal > 65535) return null + + const username = String(hop.username || '').trim() + const password = hop.password === undefined || hop.password === null ? '' : String(hop.password) + if (password && !username) return null + + return { + protocol: 'socks5', + server, + port: portVal, + username: username || undefined, + password: password || undefined, + } + } + + try { + const decoded = decodeURIComponent(encoded) + const parsed = JSON.parse(decoded) as Record + const first = normalizeHop(parsed.first) + const second = normalizeHop(parsed.second) + if (!first || !second) return null + + const localPortRaw = parsed.localPort + const localPortNum = localPortRaw === undefined || localPortRaw === null || localPortRaw === '' + ? 0 + : Number(localPortRaw) + if (!Number.isInteger(localPortNum) || localPortNum < 0 || localPortNum > 65535) return null + + return { + first, + second, + localPort: localPortNum > 0 ? localPortNum : undefined, + } + } catch { + return null + } +} + +function parseProxyInfo(proxyConfig: string): { type: string; server: string; port: number } { + const cfg = proxyConfig.trim() + if (cfg === 'direct://') return { type: 'direct', server: '-', port: 0 } + + const chain = parseChainSocks5Config(cfg) + if (chain) { + return { type: 'chain-socks5', server: '127.0.0.1', port: chain.localPort || 0 } + } + + const urlMatch = cfg.match(/^([a-zA-Z0-9+\-]+):\/\//) + if (urlMatch) { + const scheme = urlMatch[1].toLowerCase() + try { + const u = new URL(cfg) + return { type: scheme, server: u.hostname, port: parseInt(u.port) || 0 } + } catch { + return { type: scheme, server: '-', port: 0 } + } + } + try { + const parsed = yaml.load(cfg) as ClashProxy[] | ClashProxy + const proxy = Array.isArray(parsed) ? parsed[0] : parsed + return { type: proxy?.type || '-', server: proxy?.server || '-', port: proxy?.port || 0 } + } catch { + return { type: '-', server: '-', port: 0 } + } +} + +function proxyToYaml(proxy: ClashProxy): string { + return yaml.dump([proxy], { flowLevel: -1, lineWidth: -1 }).trim() +} + +function quoteYamlScalar(value: string): string { + const v = value.trim() + if (!v) return "''" + return `'${v.replace(/'/g, "''")}'` +} + +function normalizeImportedProxyArray(payload: unknown): ClashProxy[] | null { + const asArray = (input: unknown): ClashProxy[] => { + if (!Array.isArray(input)) return [] + return input.filter((item): item is ClashProxy => !!item && typeof item === 'object') + } + + if (Array.isArray(payload)) { + return asArray(payload) + } + if (!payload || typeof payload !== 'object') { + return null + } + + const record = payload as Record + if (Array.isArray(record.proxies)) { + return asArray(record.proxies) + } + if (Array.isArray(record.proxy)) { + return asArray(record.proxy) + } + if (Array.isArray(record.Proxy)) { + return asArray(record.Proxy) + } + return null +} + +function normalizeLooseClashImportText(raw: string): string { + const normalizedNewline = raw.replace(//g, '').replace(/\r\n/g, '\n').trim() + if (!normalizedNewline) return normalizedNewline + + const lines = normalizedNewline.split('\n') + const fixedLines = lines.map(line => { + const m = line.match(/^(\s*)-\s*([^,{][^,]*?)\s*,\s*(type\s*:.*)$/i) + if (!m) return line + const indent = m[1] || '' + const name = m[2] || '' + const tail = m[3] || '' + return `${indent}- { name: ${quoteYamlScalar(name)}, ${tail.trim()} }` + }) + + const hasProxiesRoot = fixedLines.some(line => /^\s*proxies\s*:/.test(line)) + if (hasProxiesRoot) { + return fixedLines.join('\n') + } + + const looksLikeProxyList = fixedLines.some(line => /^\s*-\s*/.test(line)) + if (!looksLikeProxyList) { + return fixedLines.join('\n') + } + + const indented = fixedLines.map(line => { + if (!line.trim()) return line + return ` ${line}` + }) + return `proxies:\n${indented.join('\n')}` +} + +function parseClashImportText(raw: string): ClashProxy[] { + const input = raw.trim() + if (!input) { + throw new Error('请输入 YAML 内容') + } + + const attempts = [input] + const normalized = normalizeLooseClashImportText(input) + if (normalized && normalized !== input) { + attempts.push(normalized) + } + + let lastError: unknown = null + for (const text of attempts) { + try { + const parsed = yaml.load(text) + const proxies = normalizeImportedProxyArray(parsed) + if (proxies) { + return proxies + } + } catch (error) { + lastError = error + } + } + + if (lastError && typeof lastError === 'object' && lastError !== null && 'message' in lastError) { + throw new Error(String((lastError as { message?: string }).message || '解析失败')) + } + throw new Error('无效的 YAML 格式,需要包含 proxies 数组') +} + +function normalizeDirectProxyConfig(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) return '' + if (/^socket:\/\//i.test(trimmed)) { + return trimmed.replace(/^socket:\/\//i, 'socks5://') + } + if (/^socks:\/\//i.test(trimmed)) { + return trimmed.replace(/^socks:\/\//i, 'socks5://') + } + return trimmed +} + +function resolveDirectProxyName(rawName: string, scheme: string, server: string, port: number, index: number, prefix: string): string { + const name = rawName.trim() + const fallbackName = server + ? `${scheme.toUpperCase()}-${server}${port > 0 ? `:${port}` : ''}` + : `导入代理 ${index + 1}` + const finalName = name || fallbackName + return prefix ? `${prefix}-${finalName}` : finalName +} + +function formatDirectProxyHost(raw: string): string { + const host = raw.trim() + if (!host) return '' + if (host.startsWith('[') && host.endsWith(']')) { + return host + } + return host.includes(':') ? `[${host}]` : host +} + +function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate { + const serverInput = form.server.trim() + if (!serverInput) { + throw new Error('请输入代理地址') + } + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(serverInput)) { + throw new Error('代理地址只需要填写主机名或 IP,不需要协议头') + } + + const portInput = form.port.trim() + if (!portInput) { + throw new Error('请输入代理端口') + } + if (!/^\d+$/.test(portInput)) { + throw new Error('代理端口必须为数字') + } + + const port = Number(portInput) + if (port < 1 || port > 65535) { + throw new Error('代理端口必须在 1-65535 之间') + } + + const username = form.username.trim() + const password = form.password + if (password && !username) { + throw new Error('填写密码时请同时填写账号') + } + + const auth = username + ? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ''}@` + : '' + const rawConfig = `${form.protocol}://${auth}${formatDirectProxyHost(serverInput)}:${port}` + + let parsedURL: URL + try { + parsedURL = new URL(rawConfig) + } catch { + throw new Error('请输入有效的代理地址') + } + + if (!parsedURL.hostname) { + throw new Error('请输入有效的代理地址') + } + + const normalizedConfig = normalizeDirectProxyConfig(parsedURL.toString()).replace(/\/$/, '') + const normalizedServer = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1') + + return { + proxyName: resolveDirectProxyName(form.proxyName, form.protocol, normalizedServer, port, 0, ''), + proxyConfig: normalizedConfig, + } +} + +function buildChainImportCandidate(form: ChainImportForm): ImportCandidate { + const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => { + const server = hop.server.trim() + if (!server) { + throw new Error(`请输入${label}代理地址`) + } + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) { + throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`) + } + + const portInput = hop.port.trim() + if (!portInput) { + throw new Error(`请输入${label}代理端口`) + } + if (!/^\d+$/.test(portInput)) { + throw new Error(`${label}代理端口必须为数字`) + } + + const port = Number(portInput) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${label}代理端口必须在 1-65535 之间`) + } + + const username = hop.username.trim() + const password = hop.password + if (password && !username) { + throw new Error(`${label}填写密码时请同时填写账号`) + } + + return { + protocol: 'socks5', + server, + port, + username: username || undefined, + password: password || undefined, + } + } + + const localPortInput = form.localPort.trim() + if (localPortInput && !/^\d+$/.test(localPortInput)) { + throw new Error('本地监听端口必须为数字') + } + const localPort = localPortInput ? Number(localPortInput) : 0 + if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) { + throw new Error('本地监听端口必须在 1-65535 之间') + } + + const payload: ChainSocks5Config = { + first: parseHop('第一层', form.first), + second: parseHop('第二层', form.second), + localPort: localPort > 0 ? localPort : undefined, + } + + const encodedPayload = encodeURIComponent(JSON.stringify(payload)) + const proxyConfig = `${CHAIN_SOCKS5_PREFIX}${encodedPayload}` + + return { + proxyName: form.proxyName.trim() || `链式代理-${payload.first.server}-${payload.second.server}`, + proxyConfig, + } +} + +function resolveImportedProxyName(proxy: ClashProxy, index: number, prefix: string): string { + const rawName = (proxy.name || '').trim() || `导入代理 ${index + 1}` + return prefix ? `${prefix}-${rawName}` : rawName +} + +function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] { + return parsedProxies.map((proxy, index) => ({ + proxyName: resolveImportedProxyName(proxy, index, prefix), + proxyConfig: proxyToYaml(proxy), + })) +} + +function buildImportPreview(candidates: ImportCandidate[], groupName: string): ProxyDisplayInfo[] { + return candidates.map((candidate, index) => { + const info = parseProxyInfo(candidate.proxyConfig) + return { + proxyId: `preview-${index}`, + proxyName: candidate.proxyName, + proxyConfig: candidate.proxyConfig, + groupName, + type: info.type || '-', + server: info.server || '-', + port: info.port || 0, + } + }) +} + +function normalizeRefreshIntervalM(value: number): number { + if (!Number.isFinite(value)) return 0 + if (value <= 0) return 0 + if (value < 5) return 5 + if (value > 24 * 60) return 24 * 60 + return Math.round(value) +} + +function normalizeSourceURL(sourceURL: string): string { + const raw = (sourceURL || '').trim() + if (!raw) return '' + try { + const parsed = new URL(raw) + parsed.hash = '' + return parsed.toString() + } catch { + return raw + } +} + +function buildStableSourceID(sourceURL: string, sourceNamePrefix: string): string { + const key = `${normalizeSourceURL(sourceURL)}|||${sourceNamePrefix.trim()}` + let hash = 5381 + for (let i = 0; i < key.length; i += 1) { + hash = ((hash << 5) + hash) ^ key.charCodeAt(i) + } + const unsigned = hash >>> 0 + return `src-${unsigned.toString(36)}` +} + +function resolveImportSourceID(list: BrowserProxy[], sourceURL: string, sourceNamePrefix: string): string { + const normalizedURL = normalizeSourceURL(sourceURL) + const normalizedPrefix = sourceNamePrefix.trim() + const existing = list.find(item => + normalizeSourceURL(item.sourceUrl || '') === normalizedURL && + (item.sourceNamePrefix || '').trim() === normalizedPrefix && + (item.sourceId || '').trim() !== '' + ) + if (existing?.sourceId?.trim()) { + return existing.sourceId.trim() + } + return buildStableSourceID(sourceURL, sourceNamePrefix) +} + +function nextProxyID(): string { + return `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) { + const exactMap = new Map() + const nameMap = new Map() + oldSourceProxies.forEach(item => { + const exactKey = `${item.proxyName}|||${item.proxyConfig}` + const exactList = exactMap.get(exactKey) || [] + exactList.push(item) + exactMap.set(exactKey, exactList) + + const nameKey = item.proxyName + const nameList = nameMap.get(nameKey) || [] + nameList.push(item) + nameMap.set(nameKey, nameList) + }) + + return (name: string, configText: string): string | null => { + const exactKey = `${name}|||${configText}` + const exactList = exactMap.get(exactKey) + if (exactList && exactList.length > 0) { + const item = exactList.shift() + if (item?.proxyId) return item.proxyId + } + + const nameList = nameMap.get(name) + if (nameList && nameList.length > 0) { + const item = nameList.shift() + if (item?.proxyId) return item.proxyId + } + return null + } +} + +export function ProxyImportModal({ + open, + onClose, + existingProxies, + groups, + globalAutoRefreshEnabled = false, + globalRefreshIntervalM = 60, + onImported, +}: ProxyImportModalProps) { + const [importMode, setImportMode] = useState('clash') + const [importUrl, setImportUrl] = useState('') + const [importResolvedUrl, setImportResolvedUrl] = useState('') + const [importText, setImportText] = useState('') + const [importDnsServers, setImportDnsServers] = useState('') + const [importNamePrefix, setImportNamePrefix] = useState('') + const [importGroupName, setImportGroupName] = useState('') + const [directImportForm, setDirectImportForm] = useState(() => ({ ...INITIAL_DIRECT_IMPORT_FORM })) + const [chainImportForm, setChainImportForm] = useState(() => ({ ...INITIAL_CHAIN_IMPORT_FORM })) + const [previewModalOpen, setPreviewModalOpen] = useState(false) + const [previewList, setPreviewList] = useState([]) + const [importing, setImporting] = useState(false) + const [fetchingImportUrl, setFetchingImportUrl] = useState(false) + + useEffect(() => { + if (open) return + setPreviewModalOpen(false) + }, [open]) + + const resetImportState = () => { + setImportMode('clash') + setImportUrl('') + setImportResolvedUrl('') + setImportText('') + setImportDnsServers('') + setImportNamePrefix('') + setImportGroupName('') + setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM }) + setChainImportForm({ ...INITIAL_CHAIN_IMPORT_FORM }) + setPreviewList([]) + } + + const handleImportModeChange = (nextMode: ProxyImportMode) => { + setImportMode(nextMode) + setImportResolvedUrl('') + if (nextMode !== 'clash') { + setImportUrl('') + setImportDnsServers('') + } + } + + const updateChainHop = (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => { + setChainImportForm(prev => ({ + ...prev, + [hop]: { + ...prev[hop], + [field]: value, + }, + })) + } + + const handleFetchImportURL = async () => { + const targetURL = importUrl.trim() + if (!targetURL) { + toast.error('请输入订阅 URL') + return + } + + setFetchingImportUrl(true) + try { + const result = await fetchClashImportFromURL(targetURL) + const content = (result?.content || '').trim() + if (!content) { + throw new Error('订阅内容为空') + } + + setImportResolvedUrl((result?.url || targetURL).trim()) + setImportText(content) + + if (!importDnsServers.trim() && typeof result?.dnsServers === 'string' && result.dnsServers.trim()) { + setImportDnsServers(result.dnsServers.trim()) + } + if (!importGroupName.trim() && typeof result?.suggestedGroup === 'string' && result.suggestedGroup.trim()) { + setImportGroupName(result.suggestedGroup.trim()) + } + + toast.success(`URL 获取成功,检测到 ${Math.max(0, Number(result?.proxyCount || 0))} 个代理`) + } catch (error: any) { + setImportResolvedUrl('') + toast.error(error?.message || 'URL 获取失败') + } finally { + setFetchingImportUrl(false) + } + } + + const handleParseImport = () => { + try { + const prefix = importNamePrefix.trim() + const candidates = importMode === 'clash' + ? buildImportCandidatesFromClash(parseClashImportText(importText), prefix) + : importMode === 'direct' + ? [buildDirectImportCandidate(directImportForm)] + : [buildChainImportCandidate(chainImportForm)] + if (!candidates.length) { + toast.error('未解析到可导入代理') + return + } + const preview = buildImportPreview(candidates, importGroupName.trim()) + setPreviewList(preview) + setPreviewModalOpen(true) + } catch (error: any) { + toast.error(`解析失败: ${error?.message || '未知错误'}`) + } + } + + const handleConfirmImport = async () => { + if (previewList.length === 0) { + toast.error('请至少保留 1 个代理后再导入') + return + } + setImporting(true) + try { + const sourceURL = importMode === 'clash' ? importResolvedUrl.trim() : '' + const isURLImport = !!sourceURL + const sourceNamePrefix = importMode === 'clash' ? importNamePrefix.trim() : '' + const sourceID = isURLImport ? resolveImportSourceID(existingProxies, sourceURL, sourceNamePrefix) : '' + const sourceAutoRefresh = isURLImport ? !!globalAutoRefreshEnabled : false + const sourceRefreshIntervalM = sourceAutoRefresh + ? normalizeRefreshIntervalM(Number(globalRefreshIntervalM || 0)) + : 0 + const sourceLastRefreshAt = isURLImport ? new Date().toISOString() : '' + const oldSourceProxies = isURLImport + ? existingProxies.filter(item => (item.sourceId || '').trim() === sourceID) + : [] + const pickExistingID = createExistingProxyIDPicker(oldSourceProxies) + + const newProxies: BrowserProxy[] = previewList.map((p) => ({ + proxyId: pickExistingID(p.proxyName, p.proxyConfig) || nextProxyID(), + proxyName: p.proxyName, + proxyConfig: p.proxyConfig, + dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined, + groupName: importGroupName.trim() || undefined, + sourceId: sourceID || undefined, + sourceUrl: sourceURL || undefined, + sourceNamePrefix: sourceNamePrefix || undefined, + sourceAutoRefresh, + sourceRefreshIntervalM, + sourceLastRefreshAt: sourceLastRefreshAt || undefined, + })) + const allProxies = isURLImport + ? existingProxies.filter(item => (item.sourceId || '').trim() !== sourceID).concat(newProxies) + : [...existingProxies, ...newProxies] + + await saveBrowserProxies(allProxies) + await onImported?.(newProxies) + toast.success(`成功导入 ${newProxies.length} 个代理`) + setPreviewModalOpen(false) + resetImportState() + onClose() + } catch (error: any) { + toast.error(error?.message || '导入失败') + } finally { + setImporting(false) + } + } + + const handleRemovePreviewProxy = (proxyId: string) => { + setPreviewList(prev => prev.filter(item => item.proxyId !== proxyId)) + } + + const canParseImport = importMode === 'clash' + ? !!importText.trim() + : importMode === 'direct' + ? !!directImportForm.server.trim() && !!directImportForm.port.trim() + : !!chainImportForm.first.server.trim() && !!chainImportForm.first.port.trim() && !!chainImportForm.second.server.trim() && !!chainImportForm.second.port.trim() + + const previewColumns = useMemo[]>(() => [ + { key: 'proxyName', title: '代理名称', width: '200px' }, + { key: 'type', title: '类型', width: '100px' }, + { key: 'server', title: '服务器', width: '200px' }, + { key: 'port', title: '端口', width: '100px', render: (val) => val || '-' }, + { + key: 'actions', + title: '操作', + width: '96px', + render: (_, record) => ( + + ), + }, + ], []) + + return ( + <> + + + + + } + > +
+
+ + + +
+

+ {importMode === 'clash' + ? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)' + : importMode === 'direct' + ? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接' + : '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'} +

+ {importMode === 'clash' && ( + <> + +
+ { + const next = e.target.value + setImportUrl(next) + if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) { + setImportResolvedUrl('') + } + }} + placeholder="https://example.com/clash/subscription" + className="flex-1" + /> + +
+ {importResolvedUrl.trim() && ( +

+ 已绑定订阅:{importResolvedUrl} +

+ )} +

获取成功后会自动回填 YAML 文本,并尝试自动填充 DNS 与建议分组

+
+