mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
feat(proxy): add chain proxy editing and picker row actions
Enable chain+socks5 parsing/bridge support and add row-level edit/delete actions in proxy picker with builtin proxy guardrails and deletion fallback to direct proxy.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 往返计时
|
||||
|
||||
+169
-20
@@ -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 == "" {
|
||||
|
||||
@@ -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<string, BrowserGroup>()
|
||||
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 (
|
||||
<select
|
||||
className={`px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 ${className}`}
|
||||
<Select
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{flatGroups.map(g => (
|
||||
<option key={g.groupId} value={g.groupId}>
|
||||
{' '.repeat(g.level)}{g.groupName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={options}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +1,241 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, Loader2, Search, Wifi, X } from 'lucide-react'
|
||||
import { Check, Loader2, Pencil, Plus, Search, Trash2, Wifi, X } from 'lucide-react'
|
||||
import { Button, ConfirmModal, FormItem, Input, Modal, Textarea, toast } from '../../../shared/components'
|
||||
import type { BrowserProxy } from '../types'
|
||||
import { browserProxyBatchTestSpeed, browserProxyTestSpeed, fetchBrowserProxies, fetchBrowserProxyGroups } from '../api'
|
||||
import { browserProxyBatchTestSpeed, browserProxyTestSpeed, fetchBrowserProxies, fetchBrowserProxyGroups, saveBrowserProxies } from '../api'
|
||||
import { EventsOn } from '../../../wailsjs/runtime/runtime'
|
||||
import { ProxyImportModal } from './ProxyImportModal'
|
||||
|
||||
interface ProxyPickerModalProps {
|
||||
open: boolean
|
||||
currentProxyId: string
|
||||
onSelect: (proxy: BrowserProxy) => void
|
||||
onClose: () => void
|
||||
onProxyListUpdated?: (proxies: BrowserProxy[]) => void
|
||||
onProxyDeleted?: (deletedProxyId: string, nextProxies: BrowserProxy[]) => void
|
||||
}
|
||||
|
||||
type SpeedResult = { ok: boolean; latencyMs: number; error: string }
|
||||
|
||||
const ALL_GROUP = '__all__'
|
||||
const BATCH_TEST_CONCURRENCY = 20
|
||||
type ChainSocksHop = {
|
||||
protocol?: string
|
||||
server?: string
|
||||
port?: number
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: ProxyPickerModalProps) {
|
||||
type ChainSocksConfig = {
|
||||
localPort?: number
|
||||
first?: ChainSocksHop
|
||||
second?: ChainSocksHop
|
||||
}
|
||||
|
||||
interface ChainHopForm {
|
||||
server: string
|
||||
port: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface ChainEditForm {
|
||||
proxyName: string
|
||||
localPort: string
|
||||
first: ChainHopForm
|
||||
second: ChainHopForm
|
||||
}
|
||||
|
||||
const INITIAL_CHAIN_EDIT_FORM: ChainEditForm = {
|
||||
proxyName: '',
|
||||
localPort: '',
|
||||
first: { server: '', port: '', username: '', password: '' },
|
||||
second: { server: '', port: '', username: '', password: '' },
|
||||
}
|
||||
|
||||
const LOCAL_PROXY_ID = '__local__'
|
||||
|
||||
function parseChainSocks5Config(proxyConfig: string): ChainSocksConfig | 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): ChainSocksHop | null => {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const hop = raw as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
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 toChainEditForm(proxyName: string, cfg: ChainSocksConfig): ChainEditForm {
|
||||
return {
|
||||
proxyName,
|
||||
localPort: cfg.localPort ? String(cfg.localPort) : '',
|
||||
first: {
|
||||
server: cfg.first?.server || '',
|
||||
port: cfg.first?.port ? String(cfg.first.port) : '',
|
||||
username: cfg.first?.username || '',
|
||||
password: cfg.first?.password || '',
|
||||
},
|
||||
second: {
|
||||
server: cfg.second?.server || '',
|
||||
port: cfg.second?.port ? String(cfg.second.port) : '',
|
||||
username: cfg.second?.username || '',
|
||||
password: cfg.second?.password || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildChainProxyConfig(form: ChainEditForm): string {
|
||||
const parseHop = (label: string, hop: ChainHopForm): ChainSocksHop => {
|
||||
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: ChainSocksConfig = {
|
||||
first: parseHop('第一层', form.first),
|
||||
second: parseHop('第二层', form.second),
|
||||
localPort: localPort > 0 ? localPort : undefined,
|
||||
}
|
||||
|
||||
const encodedPayload = encodeURIComponent(JSON.stringify(payload))
|
||||
return `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
|
||||
}
|
||||
const ALL_GROUP = '__all__'
|
||||
const DIRECT_PROXY_ID = '__direct__'
|
||||
const SPEED_RESULT_EVENT = 'proxy:speed:result'
|
||||
const BATCH_TEST_CONCURRENCY = 20
|
||||
const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
|
||||
|
||||
function formatProxyConfigForDisplay(proxyConfig: string): string {
|
||||
const raw = (proxyConfig || '').trim()
|
||||
if (!raw || !raw.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const encoded = raw.slice(CHAIN_SOCKS5_PREFIX.length)
|
||||
if (!encoded) return raw
|
||||
|
||||
try {
|
||||
const decoded = decodeURIComponent(encoded)
|
||||
const parsed = JSON.parse(decoded) as ChainSocksConfig
|
||||
const firstServer = (parsed.first?.server || '').trim()
|
||||
const secondServer = (parsed.second?.server || '').trim()
|
||||
if (!firstServer || !secondServer) return raw
|
||||
return `${firstServer} -> ${secondServer}`
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose, onProxyListUpdated, onProxyDeleted }: ProxyPickerModalProps) {
|
||||
const [groups, setGroups] = useState<string[]>([])
|
||||
const [allProxies, setAllProxies] = useState<BrowserProxy[]>([])
|
||||
const [displayProxies, setDisplayProxies] = useState<BrowserProxy[]>([])
|
||||
const [selectedGroup, setSelectedGroup] = useState<string>(ALL_GROUP)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
// proxyId -> speed result
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [speedMap, setSpeedMap] = useState<Record<string, SpeedResult>>({})
|
||||
const [testingIds, setTestingIds] = useState<Set<string>>(new Set())
|
||||
const [editingProxy, setEditingProxy] = useState<BrowserProxy | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editConfig, setEditConfig] = useState('')
|
||||
const [editGroup, setEditGroup] = useState('')
|
||||
const [editDnsServers, setEditDnsServers] = useState('')
|
||||
const [chainEditMode, setChainEditMode] = useState(false)
|
||||
const [chainEditForm, setChainEditForm] = useState<ChainEditForm>(INITIAL_CHAIN_EDIT_FORM)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<BrowserProxy | null>(null)
|
||||
const abortRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedGroup(ALL_GROUP)
|
||||
setSearch('')
|
||||
setSpeedMap({})
|
||||
setTestingIds(new Set())
|
||||
abortRef.current = false
|
||||
loadData()
|
||||
return () => { abortRef.current = true }
|
||||
}, [open])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -49,38 +245,56 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
])
|
||||
setGroups(groupList)
|
||||
setAllProxies(proxyList)
|
||||
// 从代理数据初始化已有测速结果
|
||||
onProxyListUpdated?.(proxyList)
|
||||
const initMap: Record<string, SpeedResult> = {}
|
||||
proxyList.forEach(p => {
|
||||
if (p.lastTestedAt) {
|
||||
initMap[p.proxyId] = { ok: p.lastTestOk ?? false, latencyMs: p.lastLatencyMs ?? -1, error: '' }
|
||||
proxyList.forEach(proxy => {
|
||||
if (proxy.lastTestedAt) {
|
||||
initMap[proxy.proxyId] = {
|
||||
ok: proxy.lastTestOk ?? false,
|
||||
latencyMs: proxy.lastLatencyMs ?? -1,
|
||||
error: '',
|
||||
}
|
||||
}
|
||||
})
|
||||
setSpeedMap(initMap)
|
||||
return proxyList
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedGroup(ALL_GROUP)
|
||||
setSearch('')
|
||||
setSpeedMap({})
|
||||
setTestingIds(new Set())
|
||||
setEditingProxy(null)
|
||||
setDeleteCandidate(null)
|
||||
abortRef.current = false
|
||||
void loadData()
|
||||
return () => { abortRef.current = true }
|
||||
}, [open])
|
||||
|
||||
const displayProxies = useMemo(() => {
|
||||
let list = allProxies
|
||||
if (selectedGroup !== ALL_GROUP) {
|
||||
list = list.filter(p => p.groupName === selectedGroup)
|
||||
list = list.filter(proxy => proxy.groupName === selectedGroup)
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase()
|
||||
list = list.filter(p =>
|
||||
(p.proxyName || '').toLowerCase().includes(q) ||
|
||||
(p.proxyConfig || '').toLowerCase().includes(q)
|
||||
const query = search.trim().toLowerCase()
|
||||
list = list.filter(proxy =>
|
||||
(proxy.proxyName || '').toLowerCase().includes(query) ||
|
||||
(proxy.proxyConfig || '').toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const getSortTuple = (proxy: BrowserProxy): [number, number, string] => {
|
||||
const latest = speedMap[proxy.proxyId]
|
||||
const fromHistory = proxy.lastTestedAt
|
||||
const history = proxy.lastTestedAt
|
||||
? { ok: proxy.lastTestOk ?? false, latencyMs: proxy.lastLatencyMs ?? -1 }
|
||||
: undefined
|
||||
const result = latest || fromHistory
|
||||
const result = latest || history
|
||||
|
||||
if (result?.ok && result.latencyMs >= 0) {
|
||||
return [0, result.latencyMs, proxy.proxyName || '']
|
||||
@@ -94,17 +308,29 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
return [4, Number.MAX_SAFE_INTEGER, proxy.proxyName || '']
|
||||
}
|
||||
|
||||
list = [...list].sort((a, b) => {
|
||||
const [rankA, latencyA, nameA] = getSortTuple(a)
|
||||
const [rankB, latencyB, nameB] = getSortTuple(b)
|
||||
if (rankA !== rankB) return rankA - rankB
|
||||
if (latencyA !== latencyB) return latencyA - latencyB
|
||||
return nameA.localeCompare(nameB, 'zh-CN')
|
||||
})
|
||||
|
||||
setDisplayProxies(list)
|
||||
return [...list]
|
||||
.sort((a, b) => {
|
||||
const [rankA, latencyA, nameA] = getSortTuple(a)
|
||||
const [rankB, latencyB, nameB] = getSortTuple(b)
|
||||
if (rankA !== rankB) return rankA - rankB
|
||||
if (latencyA !== latencyB) return latencyA - latencyB
|
||||
return nameA.localeCompare(nameB, 'zh-CN')
|
||||
})
|
||||
.map(proxy => ({
|
||||
proxy,
|
||||
displayConfig: formatProxyConfigForDisplay(proxy.proxyConfig),
|
||||
}))
|
||||
}, [selectedGroup, search, allProxies, speedMap])
|
||||
|
||||
const groupCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
allProxies.forEach(proxy => {
|
||||
const key = proxy.groupName || ''
|
||||
counts.set(key, (counts.get(key) || 0) + 1)
|
||||
})
|
||||
return counts
|
||||
}, [allProxies])
|
||||
|
||||
const testOne = async (proxyId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (testingIds.has(proxyId)) return
|
||||
@@ -112,39 +338,61 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
try {
|
||||
const result = await browserProxyTestSpeed(proxyId)
|
||||
if (!abortRef.current) {
|
||||
setSpeedMap(prev => ({ ...prev, [proxyId]: { ok: result.ok, latencyMs: result.latencyMs, error: result.error } }))
|
||||
setSpeedMap(prev => ({
|
||||
...prev,
|
||||
[proxyId]: { ok: result.ok, latencyMs: result.latencyMs, error: result.error },
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
setTestingIds(prev => { const s = new Set(prev); s.delete(proxyId); return s })
|
||||
setTestingIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(proxyId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const testAll = async () => {
|
||||
const ids = displayProxies.map(p => p.proxyId).filter(id => id !== '__direct__')
|
||||
const ids = displayProxies.map(item => item.proxy.proxyId).filter(id => id !== DIRECT_PROXY_ID)
|
||||
if (ids.length === 0) return
|
||||
|
||||
abortRef.current = false
|
||||
setTestingIds(new Set(ids))
|
||||
const idSet = new Set(ids)
|
||||
const off = EventsOn('proxy:speed:result', (data: { proxyId: string; ok: boolean; latencyMs: number; error: string }) => {
|
||||
|
||||
const off = EventsOn(SPEED_RESULT_EVENT, (data: { proxyId: string; ok: boolean; latencyMs: number; error: string }) => {
|
||||
if (abortRef.current || !idSet.has(data.proxyId)) return
|
||||
setSpeedMap(prev => ({ ...prev, [data.proxyId]: { ok: data.ok, latencyMs: data.latencyMs, error: data.error } }))
|
||||
setSpeedMap(prev => ({
|
||||
...prev,
|
||||
[data.proxyId]: { ok: data.ok, latencyMs: data.latencyMs, error: data.error },
|
||||
}))
|
||||
setTestingIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(data.proxyId)
|
||||
return next
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const results = await browserProxyBatchTestSpeed(ids, BATCH_TEST_CONCURRENCY)
|
||||
if (!abortRef.current) {
|
||||
setSpeedMap(prev => {
|
||||
const next = { ...prev }
|
||||
let changed = false
|
||||
results.forEach(result => {
|
||||
if (idSet.has(result.proxyId)) {
|
||||
if (!idSet.has(result.proxyId)) return
|
||||
const current = next[result.proxyId]
|
||||
if (
|
||||
!current ||
|
||||
current.ok !== result.ok ||
|
||||
current.latencyMs !== result.latencyMs ||
|
||||
current.error !== result.error
|
||||
) {
|
||||
next[result.proxyId] = { ok: result.ok, latencyMs: result.latencyMs, error: result.error }
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
return next
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
@@ -157,6 +405,119 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
}
|
||||
}
|
||||
|
||||
const handleImported = async (newProxies: BrowserProxy[]) => {
|
||||
const refreshed = await loadData()
|
||||
const targetProxyId = newProxies[newProxies.length - 1]?.proxyId
|
||||
if (!targetProxyId) return
|
||||
const selected = refreshed.find(proxy => proxy.proxyId === targetProxyId)
|
||||
if (!selected) return
|
||||
onSelect(selected)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleEditClick = (proxy: BrowserProxy, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (proxy.proxyId === DIRECT_PROXY_ID) return
|
||||
setEditingProxy(proxy)
|
||||
setEditName(proxy.proxyName || '')
|
||||
setEditConfig(proxy.proxyConfig || '')
|
||||
setEditGroup(proxy.groupName || '')
|
||||
setEditDnsServers(proxy.dnsServers || '')
|
||||
|
||||
const chainCfg = parseChainSocks5Config(proxy.proxyConfig || '')
|
||||
if (chainCfg) {
|
||||
setChainEditMode(true)
|
||||
setChainEditForm(toChainEditForm(proxy.proxyName || '', chainCfg))
|
||||
} else {
|
||||
setChainEditMode(false)
|
||||
setChainEditForm(INITIAL_CHAIN_EDIT_FORM)
|
||||
}
|
||||
}
|
||||
|
||||
const updateChainHop = (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => {
|
||||
setChainEditForm(prev => ({
|
||||
...prev,
|
||||
[hop]: {
|
||||
...prev[hop],
|
||||
[field]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditingProxy(null)
|
||||
setSavingEdit(false)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingProxy) return
|
||||
const nextName = chainEditMode ? chainEditForm.proxyName.trim() : editName.trim()
|
||||
if (!nextName) {
|
||||
toast.error('请输入代理名称')
|
||||
return
|
||||
}
|
||||
|
||||
let nextConfig = editConfig.trim()
|
||||
if (chainEditMode) {
|
||||
try {
|
||||
nextConfig = buildChainProxyConfig(chainEditForm)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '链式代理配置无效')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const nextProxies = allProxies.map(item =>
|
||||
item.proxyId === editingProxy.proxyId
|
||||
? {
|
||||
...item,
|
||||
proxyName: nextName,
|
||||
proxyConfig: nextConfig,
|
||||
groupName: editGroup.trim() || undefined,
|
||||
dnsServers: editDnsServers.trim() || undefined,
|
||||
}
|
||||
: item
|
||||
)
|
||||
|
||||
setSavingEdit(true)
|
||||
try {
|
||||
await saveBrowserProxies(nextProxies)
|
||||
setAllProxies(nextProxies)
|
||||
onProxyListUpdated?.(nextProxies)
|
||||
if (editingProxy.proxyId === currentProxyId) {
|
||||
const updated = nextProxies.find(item => item.proxyId === currentProxyId)
|
||||
if (updated) onSelect(updated)
|
||||
}
|
||||
toast.success('代理已更新')
|
||||
closeEditModal()
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '保存失败')
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteClick = (proxy: BrowserProxy, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (proxy.proxyId === DIRECT_PROXY_ID || proxy.proxyId === LOCAL_PROXY_ID) return
|
||||
setDeleteCandidate(proxy)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteCandidate) return
|
||||
const nextProxies = allProxies.filter(item => item.proxyId !== deleteCandidate.proxyId)
|
||||
try {
|
||||
await saveBrowserProxies(nextProxies)
|
||||
setAllProxies(nextProxies)
|
||||
onProxyListUpdated?.(nextProxies)
|
||||
onProxyDeleted?.(deleteCandidate.proxyId, nextProxies)
|
||||
toast.success('代理已删除')
|
||||
setDeleteCandidate(null)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
@@ -166,7 +527,6 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
className="relative bg-[var(--color-bg-elevated)] border border-[var(--color-border)] rounded-xl shadow-2xl w-[720px] max-h-[580px] flex flex-col"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
|
||||
<span className="font-semibold text-[var(--color-text-primary)]">从代理池选择</span>
|
||||
<button onClick={onClose} className="text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
@@ -175,20 +535,21 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left: group list */}
|
||||
<div className="w-44 border-r border-[var(--color-border)] flex flex-col py-2 overflow-y-auto shrink-0 bg-[var(--color-bg-muted)]">
|
||||
<GroupItem label="全部" active={selectedGroup === ALL_GROUP} count={allProxies.length} onClick={() => setSelectedGroup(ALL_GROUP)} />
|
||||
{groups.map(g => (
|
||||
<GroupItem key={g} label={g} active={selectedGroup === g}
|
||||
count={allProxies.filter(p => p.groupName === g).length}
|
||||
onClick={() => setSelectedGroup(g)} />
|
||||
{groups.map(groupName => (
|
||||
<GroupItem
|
||||
key={groupName}
|
||||
label={groupName}
|
||||
active={selectedGroup === groupName}
|
||||
count={groupCounts.get(groupName) || 0}
|
||||
onClick={() => setSelectedGroup(groupName)}
|
||||
/>
|
||||
))}
|
||||
{groups.length === 0 && <p className="text-xs text-[var(--color-text-muted)] px-3 py-2">暂无分组</p>}
|
||||
</div>
|
||||
|
||||
{/* Right: proxy list */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Search + test all */}
|
||||
<div className="px-3 py-2 border-b border-[var(--color-border)] flex gap-2 items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-muted)]" />
|
||||
@@ -200,6 +561,13 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm bg-[var(--color-bg-input)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus:outline-none focus:border-[var(--color-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:border-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
导入代理
|
||||
</button>
|
||||
<button
|
||||
onClick={testAll}
|
||||
disabled={testingIds.size > 0 || displayProxies.length === 0}
|
||||
@@ -210,22 +578,24 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-[var(--color-text-muted)]">加载中...</div>
|
||||
) : displayProxies.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-[var(--color-text-muted)]">暂无代理</div>
|
||||
) : (
|
||||
displayProxies.map(proxy => (
|
||||
displayProxies.map(item => (
|
||||
<ProxyRow
|
||||
key={proxy.proxyId}
|
||||
proxy={proxy}
|
||||
selected={proxy.proxyId === currentProxyId}
|
||||
testing={testingIds.has(proxy.proxyId)}
|
||||
speedResult={speedMap[proxy.proxyId]}
|
||||
onSelect={() => { onSelect(proxy); onClose() }}
|
||||
onTest={e => testOne(proxy.proxyId, e)}
|
||||
key={item.proxy.proxyId}
|
||||
proxy={item.proxy}
|
||||
selected={item.proxy.proxyId === currentProxyId}
|
||||
testing={testingIds.has(item.proxy.proxyId)}
|
||||
speedResult={speedMap[item.proxy.proxyId]}
|
||||
displayConfig={item.displayConfig}
|
||||
onSelect={() => { onSelect(item.proxy); onClose() }}
|
||||
onTest={e => testOne(item.proxy.proxyId, e)}
|
||||
onEdit={e => handleEditClick(item.proxy, e)}
|
||||
onDelete={e => handleDeleteClick(item.proxy, e)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -233,11 +603,131 @@ export function ProxyPickerModal({ open, currentProxyId, onSelect, onClose }: Pr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-[var(--color-border)] text-xs text-[var(--color-text-muted)]">
|
||||
共 {displayProxies.length} 条,点击行即选中
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProxyImportModal
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
existingProxies={allProxies}
|
||||
groups={groups}
|
||||
onImported={handleImported}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={!!editingProxy}
|
||||
onClose={closeEditModal}
|
||||
title="编辑代理"
|
||||
width="520px"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeEditModal} disabled={savingEdit}>取消</Button>
|
||||
<Button onClick={handleSaveEdit} loading={savingEdit}>保存</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FormItem label="代理名称" required>
|
||||
<Input
|
||||
value={chainEditMode ? chainEditForm.proxyName : editName}
|
||||
onChange={e => {
|
||||
if (chainEditMode) {
|
||||
setChainEditForm(prev => ({ ...prev, proxyName: e.target.value }))
|
||||
} else {
|
||||
setEditName(e.target.value)
|
||||
}
|
||||
}}
|
||||
placeholder="例如:香港节点"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="分组名称(可选)">
|
||||
<Input value={editGroup} onChange={e => setEditGroup(e.target.value)} placeholder="例如:香港、美国" />
|
||||
</FormItem>
|
||||
|
||||
{chainEditMode ? (
|
||||
<div className="space-y-3 rounded-md border border-[var(--color-border)] p-3">
|
||||
<FormItem label="本地监听端口(可选)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainEditForm.localPort}
|
||||
onChange={e => setChainEditForm(prev => ({ ...prev, localPort: e.target.value }))}
|
||||
placeholder="留空自动分配"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层 SOCKS5</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理地址" required>
|
||||
<Input value={chainEditForm.first.server} onChange={e => updateChainHop('first', 'server', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input type="number" min={1} max={65535} value={chainEditForm.first.port} onChange={e => updateChainHop('first', 'port', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input value={chainEditForm.first.username} onChange={e => updateChainHop('first', 'username', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input type="password" value={chainEditForm.first.password} onChange={e => updateChainHop('first', 'password', e.target.value)} />
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层 SOCKS5</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理地址" required>
|
||||
<Input value={chainEditForm.second.server} onChange={e => updateChainHop('second', 'server', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input type="number" min={1} max={65535} value={chainEditForm.second.port} onChange={e => updateChainHop('second', 'port', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input value={chainEditForm.second.username} onChange={e => updateChainHop('second', 'username', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input type="password" value={chainEditForm.second.password} onChange={e => updateChainHop('second', 'password', e.target.value)} />
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FormItem label="代理配置" required>
|
||||
<Textarea
|
||||
value={editConfig}
|
||||
onChange={e => setEditConfig(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="支持 http://、https://、socks5://、chain+socks5://"
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
<FormItem label="DNS 服务器(可选)">
|
||||
<Textarea
|
||||
value={editDnsServers}
|
||||
onChange={e => setEditDnsServers(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteCandidate}
|
||||
onClose={() => setDeleteCandidate(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="删除代理"
|
||||
content={`确认删除代理「${deleteCandidate?.proxyName || ''}」?`}
|
||||
confirmText="确认删除"
|
||||
cancelText="取消"
|
||||
danger
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
@@ -264,8 +754,11 @@ interface ProxyRowProps {
|
||||
selected: boolean
|
||||
testing: boolean
|
||||
speedResult?: SpeedResult
|
||||
displayConfig: string
|
||||
onSelect: () => void
|
||||
onTest: (e: React.MouseEvent) => void
|
||||
onEdit: (e: React.MouseEvent) => void
|
||||
onDelete: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResult }) {
|
||||
@@ -276,7 +769,11 @@ function SpeedBadge({ testing, result }: { testing: boolean; result?: SpeedResul
|
||||
return <span className={`text-xs font-medium shrink-0 ${color}`}>{result.latencyMs}ms</span>
|
||||
}
|
||||
|
||||
function ProxyRow({ proxy, selected, testing, speedResult, onSelect, onTest }: ProxyRowProps) {
|
||||
function ProxyRow({ proxy, selected, testing, speedResult, displayConfig, onSelect, onTest, onEdit, onDelete }: ProxyRowProps) {
|
||||
const isDirect = proxy.proxyId === DIRECT_PROXY_ID
|
||||
const isLocal = proxy.proxyId === LOCAL_PROXY_ID
|
||||
const disableDelete = isDirect || isLocal
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
@@ -290,7 +787,7 @@ function ProxyRow({ proxy, selected, testing, speedResult, onSelect, onTest }: P
|
||||
{proxy.groupName && <span className="ml-2 text-xs text-[var(--color-primary)]/70 font-normal">[{proxy.groupName}]</span>}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-muted)] truncate mt-0.5 w-0 min-w-full">
|
||||
{proxy.proxyConfig}
|
||||
{displayConfig}
|
||||
</div>
|
||||
</div>
|
||||
<SpeedBadge testing={testing} result={speedResult} />
|
||||
@@ -302,6 +799,22 @@ function ProxyRow({ proxy, selected, testing, speedResult, onSelect, onTest }: P
|
||||
>
|
||||
<Wifi className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
disabled={isDirect}
|
||||
title={isDirect ? '直连不可编辑' : '编辑代理'}
|
||||
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={disableDelete}
|
||||
title={isDirect ? '直连不可删除' : isLocal ? '本地代理不可删除' : '删除代理'}
|
||||
className="shrink-0 p-1 rounded text-[var(--color-text-muted)] hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{selected && <Check className="w-4 h-4 text-[var(--color-primary)] shrink-0" />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ import { GroupSelector } from '../components/GroupSelector'
|
||||
import { ProxyPickerModal } from '../components/ProxyPickerModal'
|
||||
|
||||
const fallbackLowLaunchArgs = ['--disable-sync', '--no-first-run']
|
||||
const BROWSER_LIST_ROUTE = '/browser/list'
|
||||
const DIRECT_PROXY_ID = '__direct__'
|
||||
|
||||
function normalizeLaunchArgs(args: string[]): string[] {
|
||||
return (args || []).map(item => item.trim()).filter(Boolean)
|
||||
@@ -110,7 +112,7 @@ export function BrowserEditPage() {
|
||||
toast.success('配置已更新')
|
||||
}
|
||||
setIsDirty(false)
|
||||
navigate('/browser/list')
|
||||
navigate(BROWSER_LIST_ROUTE)
|
||||
} catch (error: any) {
|
||||
setSaveError(typeof error === 'string' ? error : error?.message || '保存失败')
|
||||
} finally {
|
||||
@@ -119,7 +121,7 @@ export function BrowserEditPage() {
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (isDirty) { setLeaveConfirm(true) } else { navigate('/browser/list') }
|
||||
if (isDirty) { setLeaveConfirm(true) } else { navigate(BROWSER_LIST_ROUTE) }
|
||||
}
|
||||
|
||||
const defaultCore = cores.find(c => c.isDefault)
|
||||
@@ -136,6 +138,22 @@ export function BrowserEditPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleProxyListUpdated = (nextProxies: BrowserProxy[]) => {
|
||||
setProxies(nextProxies)
|
||||
}
|
||||
|
||||
const handleProxyDeleted = (deletedProxyId: string, nextProxies: BrowserProxy[]) => {
|
||||
setProxies(nextProxies)
|
||||
if (formData.proxyId === deletedProxyId) {
|
||||
const fallbackProxy = nextProxies.find(item => item.proxyId === DIRECT_PROXY_ID)
|
||||
if (fallbackProxy) {
|
||||
handleChange('proxyId', DIRECT_PROXY_ID)
|
||||
} else {
|
||||
handleChange('proxyId', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -236,7 +254,12 @@ export function BrowserEditPage() {
|
||||
<ProxyPickerModal
|
||||
open={proxyPickerOpen}
|
||||
currentProxyId={formData.proxyId}
|
||||
onSelect={proxy => handleChange('proxyId', proxy.proxyId)}
|
||||
onSelect={proxy => {
|
||||
handleChange('proxyId', proxy.proxyId)
|
||||
setProxies(prev => prev.some(item => item.proxyId === proxy.proxyId) ? prev : [...prev, proxy])
|
||||
}}
|
||||
onProxyListUpdated={handleProxyListUpdated}
|
||||
onProxyDeleted={handleProxyDeleted}
|
||||
onClose={() => setProxyPickerOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -264,7 +287,7 @@ export function BrowserEditPage() {
|
||||
<ConfirmModal
|
||||
open={leaveConfirm}
|
||||
onClose={() => setLeaveConfirm(false)}
|
||||
onConfirm={() => navigate('/browser/list')}
|
||||
onConfirm={() => navigate(BROWSER_LIST_ROUTE)}
|
||||
title="放弃未保存的更改?"
|
||||
content="当前页面有未保存的修改,离开后将丢失这些更改。"
|
||||
confirmText="放弃并离开"
|
||||
|
||||
@@ -15,6 +15,10 @@ const PROXY_GLOBAL_AUTO_REFRESH_KEY = 'browser:proxyPool:globalAutoRefreshEnable
|
||||
const PROXY_GLOBAL_REFRESH_INTERVAL_KEY = 'browser:proxyPool:globalRefreshIntervalM:v1'
|
||||
const PROXY_LATENCY_CACHE_TTL_MS = 12 * 60 * 60 * 1000
|
||||
const PROXY_IP_HEALTH_CACHE_TTL_MS = 12 * 60 * 60 * 1000
|
||||
const SPEED_RESULT_EVENT = 'proxy:speed:result'
|
||||
const IP_HEALTH_RESULT_EVENT = 'proxy:iphealth:result'
|
||||
const PROXY_SPEED_TEST_CONCURRENCY = 20
|
||||
const PROXY_IP_HEALTH_TEST_CONCURRENCY = 10
|
||||
|
||||
const BUILTIN_PROXIES: BrowserProxy[] = [
|
||||
{ proxyId: '__direct__', proxyName: '直连(不走代理)', proxyConfig: 'direct://' },
|
||||
@@ -39,7 +43,7 @@ interface ClashProxy {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
type ProxyImportMode = 'clash' | 'direct'
|
||||
type ProxyImportMode = 'clash' | 'direct' | 'chain'
|
||||
|
||||
interface DirectImportForm {
|
||||
proxyName: string
|
||||
@@ -50,6 +54,20 @@ interface DirectImportForm {
|
||||
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' },
|
||||
@@ -65,6 +83,23 @@ const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
|
||||
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
|
||||
@@ -97,9 +132,108 @@ interface URLImportSourceMeta {
|
||||
sourceLastRefreshAt: string
|
||||
}
|
||||
|
||||
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 toChainImportForm(proxyName: string, cfg: ChainSocks5Config): ChainImportForm {
|
||||
return {
|
||||
proxyName,
|
||||
localPort: cfg.localPort ? String(cfg.localPort) : '',
|
||||
first: {
|
||||
server: cfg.first.server,
|
||||
port: String(cfg.first.port),
|
||||
username: cfg.first.username || '',
|
||||
password: cfg.first.password || '',
|
||||
},
|
||||
second: {
|
||||
server: cfg.second.server,
|
||||
port: String(cfg.second.port),
|
||||
username: cfg.second.username || '',
|
||||
password: cfg.second.password || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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()
|
||||
@@ -319,6 +453,69 @@ function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
|
||||
}
|
||||
}
|
||||
|
||||
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 buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
|
||||
return parsedProxies.map((proxy, index) => ({
|
||||
proxyName: resolveImportedProxyName(proxy, index, prefix),
|
||||
@@ -721,6 +918,8 @@ export function ProxyPoolPage() {
|
||||
const [importNamePrefix, setImportNamePrefix] = useState('')
|
||||
const [importGroupName, setImportGroupName] = useState('')
|
||||
const [directImportForm, setDirectImportForm] = useState<DirectImportForm>(() => ({ ...INITIAL_DIRECT_IMPORT_FORM }))
|
||||
const [chainImportForm, setChainImportForm] = useState<ChainImportForm>(() => ({ ...INITIAL_CHAIN_IMPORT_FORM }))
|
||||
const [chainEditMode, setChainEditMode] = useState(false)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
const [previewList, setPreviewList] = useState<ProxyDisplayInfo[]>([])
|
||||
const [removedPreviewProxyNames, setRemovedPreviewProxyNames] = useState<string[]>([])
|
||||
@@ -1094,14 +1293,14 @@ export function ProxyPoolPage() {
|
||||
setLatencyMap(prev => ({ ...prev, ...init }))
|
||||
|
||||
// 监听后端实时推送的单个测速结果
|
||||
const off = EventsOn('proxy:speed:result', (data: { proxyId: string; ok: boolean; latencyMs: number; error: string }) => {
|
||||
const off = EventsOn(SPEED_RESULT_EVENT, (data: { proxyId: string; ok: boolean; latencyMs: number; error: string }) => {
|
||||
const val = toLatencyValue(data.ok, data.latencyMs, data.error)
|
||||
setLatencyMap(prev => ({ ...prev, [data.proxyId]: val }))
|
||||
})
|
||||
|
||||
try {
|
||||
const proxyIds = testable.map(p => p.proxyId)
|
||||
const results = await browserProxyBatchTestSpeed(proxyIds, 20)
|
||||
const results = await browserProxyBatchTestSpeed(proxyIds, PROXY_SPEED_TEST_CONCURRENCY)
|
||||
setLatencyMap(prev => {
|
||||
const next = { ...prev }
|
||||
results.forEach(result => {
|
||||
@@ -1147,7 +1346,7 @@ export function ProxyPoolPage() {
|
||||
const idSet = new Set(ids)
|
||||
setCheckingIPHealthIds(prev => new Set([...Array.from(prev), ...ids]))
|
||||
|
||||
const off = EventsOn('proxy:iphealth:result', (data: ProxyIPHealthResult) => {
|
||||
const off = EventsOn(IP_HEALTH_RESULT_EVENT, (data: ProxyIPHealthResult) => {
|
||||
if (!data?.proxyId || !idSet.has(data.proxyId)) return
|
||||
setIPHealthMap(prev => ({ ...prev, [data.proxyId]: data }))
|
||||
setCheckingIPHealthIds(prev => {
|
||||
@@ -1158,7 +1357,7 @@ export function ProxyPoolPage() {
|
||||
})
|
||||
|
||||
try {
|
||||
const results = await browserProxyBatchCheckIPHealth(ids, 10)
|
||||
const results = await browserProxyBatchCheckIPHealth(ids, PROXY_IP_HEALTH_TEST_CONCURRENCY)
|
||||
setIPHealthMap(prev => {
|
||||
const next = { ...prev }
|
||||
results.forEach(result => {
|
||||
@@ -1295,6 +1494,7 @@ export function ProxyPoolPage() {
|
||||
width: '320px',
|
||||
render: (_, record) => {
|
||||
const isBuiltin = BUILTIN_PROXY_IDS.has(record.proxyId)
|
||||
const isEditLocked = record.proxyId === '__direct__'
|
||||
const hasSource = !!record.sourceId && !!record.sourceUrl
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
@@ -1322,9 +1522,9 @@ export function ProxyPoolPage() {
|
||||
>IP健康</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
disabled={isBuiltin}
|
||||
title={isBuiltin ? '内置代理不可编辑' : undefined}
|
||||
onClick={(e) => { e.stopPropagation(); if (!isBuiltin) handleEdit(record) }}
|
||||
disabled={isEditLocked}
|
||||
title={isEditLocked ? '直连代理不可编辑' : undefined}
|
||||
onClick={(e) => { e.stopPropagation(); if (!isEditLocked) handleEdit(record) }}
|
||||
>编辑</Button>
|
||||
<Button
|
||||
size="sm" variant="danger"
|
||||
@@ -1370,23 +1570,37 @@ export function ProxyPoolPage() {
|
||||
const proxy = proxies.find(p => p.proxyId === record.proxyId)
|
||||
if (proxy) {
|
||||
setEditingProxy(proxy)
|
||||
setEditForm({ proxyName: proxy.proxyName, proxyConfig: proxy.proxyConfig, dnsServers: proxy.dnsServers || '', groupName: proxy.groupName || '' })
|
||||
const chainCfg = parseChainSocks5Config(proxy.proxyConfig)
|
||||
if (chainCfg) {
|
||||
setChainImportForm(toChainImportForm(proxy.proxyName, chainCfg))
|
||||
setChainEditMode(true)
|
||||
setEditForm({ proxyName: proxy.proxyName, proxyConfig: proxy.proxyConfig, dnsServers: proxy.dnsServers || '', groupName: proxy.groupName || '' })
|
||||
} else {
|
||||
setChainEditMode(false)
|
||||
setEditForm({ proxyName: proxy.proxyName, proxyConfig: proxy.proxyConfig, dnsServers: proxy.dnsServers || '', groupName: proxy.groupName || '' })
|
||||
}
|
||||
setEditModalOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProxy = async () => {
|
||||
if (!editForm.proxyName.trim()) { toast.error('请输入代理名称'); return }
|
||||
const isChainEditing = chainEditMode
|
||||
const nextProxyName = isChainEditing ? chainImportForm.proxyName.trim() : editForm.proxyName.trim()
|
||||
if (!nextProxyName) { toast.error('请输入代理名称'); return }
|
||||
if (!editingProxy) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const nextProxyConfig = isChainEditing
|
||||
? buildChainImportCandidate(chainImportForm).proxyConfig
|
||||
: editForm.proxyConfig
|
||||
const newProxies = proxies.map(p =>
|
||||
p.proxyId === editingProxy.proxyId
|
||||
? { ...p, proxyName: editForm.proxyName, proxyConfig: editForm.proxyConfig, dnsServers: editForm.dnsServers, groupName: editForm.groupName }
|
||||
? { ...p, proxyName: nextProxyName, proxyConfig: nextProxyConfig, dnsServers: editForm.dnsServers, groupName: editForm.groupName }
|
||||
: p
|
||||
)
|
||||
await saveProxies(newProxies)
|
||||
setEditModalOpen(false)
|
||||
setChainEditMode(false)
|
||||
toast.success('代理已更新')
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '保存失败')
|
||||
@@ -1422,6 +1636,16 @@ export function ProxyPoolPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1461,7 +1685,9 @@ export function ProxyPoolPage() {
|
||||
const prefix = importNamePrefix.trim()
|
||||
const candidates = importMode === 'clash'
|
||||
? buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
|
||||
: [buildDirectImportCandidate(directImportForm)]
|
||||
: importMode === 'direct'
|
||||
? [buildDirectImportCandidate(directImportForm)]
|
||||
: [buildChainImportCandidate(chainImportForm)]
|
||||
if (!candidates.length) {
|
||||
toast.error('未解析到可导入代理')
|
||||
return
|
||||
@@ -1523,6 +1749,7 @@ export function ProxyPoolPage() {
|
||||
setImportNamePrefix('')
|
||||
setImportGroupName('')
|
||||
setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM })
|
||||
setChainImportForm({ ...INITIAL_CHAIN_IMPORT_FORM })
|
||||
setPreviewList([])
|
||||
setRemovedPreviewProxyNames([])
|
||||
toast.success(`成功导入 ${newProxies.length} 个代理`)
|
||||
@@ -1536,7 +1763,9 @@ export function ProxyPoolPage() {
|
||||
const selectedCount = selectedIds.size
|
||||
const canParseImport = importMode === 'clash'
|
||||
? !!importText.trim()
|
||||
: !!directImportForm.server.trim() && !!directImportForm.port.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()
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
@@ -1648,7 +1877,7 @@ export function ProxyPoolPage() {
|
||||
</>
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={importMode === 'clash' ? undefined : 'secondary'}
|
||||
onClick={() => handleImportModeChange('clash')}
|
||||
@@ -1661,11 +1890,19 @@ export function ProxyPoolPage() {
|
||||
>
|
||||
HTTP / SOCKS5(测试中)
|
||||
</Button>
|
||||
<Button
|
||||
variant={importMode === 'chain' ? undefined : 'secondary'}
|
||||
onClick={() => handleImportModeChange('chain')}
|
||||
>
|
||||
链式代理
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
{importMode === 'clash'
|
||||
? '支持粘贴 Clash YAML,或通过订阅 URL 自动拉取并解析(含 proxies、dns、proxy-groups)'
|
||||
: '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'}
|
||||
: importMode === 'direct'
|
||||
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'
|
||||
: '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
|
||||
</p>
|
||||
{importMode === 'clash' && (
|
||||
<>
|
||||
@@ -1757,6 +1994,106 @@ export function ProxyPoolPage() {
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
{importMode === 'chain' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理名称(可选)">
|
||||
<Input
|
||||
value={chainImportForm.proxyName}
|
||||
onChange={e => setChainImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
|
||||
placeholder="例如:双层香港链路"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="本地监听端口(可选)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainImportForm.localPort}
|
||||
onChange={e => setChainImportForm(prev => ({ ...prev, localPort: e.target.value }))}
|
||||
placeholder="留空自动分配"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层 SOCKS5</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={chainImportForm.first.server}
|
||||
onChange={e => updateChainHop('first', 'server', e.target.value)}
|
||||
placeholder="例如:s1.example.com"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainImportForm.first.port}
|
||||
onChange={e => updateChainHop('first', 'port', e.target.value)}
|
||||
placeholder="例如:1080"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={chainImportForm.first.username}
|
||||
onChange={e => updateChainHop('first', 'username', e.target.value)}
|
||||
placeholder="留空则不使用认证"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={chainImportForm.first.password}
|
||||
onChange={e => updateChainHop('first', 'password', e.target.value)}
|
||||
placeholder="留空则不使用密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层 SOCKS5</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理地址" required>
|
||||
<Input
|
||||
value={chainImportForm.second.server}
|
||||
onChange={e => updateChainHop('second', 'server', e.target.value)}
|
||||
placeholder="例如:s2.example.com"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainImportForm.second.port}
|
||||
onChange={e => updateChainHop('second', 'port', e.target.value)}
|
||||
placeholder="例如:1081"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input
|
||||
value={chainImportForm.second.username}
|
||||
onChange={e => updateChainHop('second', 'username', e.target.value)}
|
||||
placeholder="留空则不使用认证"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input
|
||||
type="password"
|
||||
value={chainImportForm.second.password}
|
||||
onChange={e => updateChainHop('second', 'password', e.target.value)}
|
||||
placeholder="留空则不使用密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormItem label="分组名称(可选)">
|
||||
<Input
|
||||
value={importGroupName}
|
||||
@@ -1810,7 +2147,17 @@ export function ProxyPoolPage() {
|
||||
footer={<><Button variant="secondary" onClick={() => setEditModalOpen(false)}>取消</Button><Button onClick={handleSaveProxy} loading={saving}>保存</Button></>}>
|
||||
<div className="space-y-4">
|
||||
<FormItem label="代理名称" required>
|
||||
<Input value={editForm.proxyName} onChange={e => setEditForm(prev => ({ ...prev, proxyName: e.target.value }))} placeholder="例如:香港节点" />
|
||||
<Input
|
||||
value={chainEditMode ? chainImportForm.proxyName : editForm.proxyName}
|
||||
onChange={e => {
|
||||
if (chainEditMode) {
|
||||
setChainImportForm(prev => ({ ...prev, proxyName: e.target.value }))
|
||||
} else {
|
||||
setEditForm(prev => ({ ...prev, proxyName: e.target.value }))
|
||||
}
|
||||
}}
|
||||
placeholder="例如:香港节点"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="分组名称(可选)">
|
||||
<Input value={editForm.groupName} onChange={e => setEditForm(prev => ({ ...prev, groupName: e.target.value }))} placeholder="例如:香港、美国" list="edit-proxy-groups-datalist" />
|
||||
@@ -1818,9 +2165,61 @@ export function ProxyPoolPage() {
|
||||
{groups.map(g => <option key={g} value={g} />)}
|
||||
</datalist>
|
||||
</FormItem>
|
||||
<FormItem label="代理配置">
|
||||
<Textarea value={editForm.proxyConfig} onChange={e => setEditForm(prev => ({ ...prev, proxyConfig: e.target.value }))} rows={10} placeholder="支持 Clash YAML、http://、https://、socks5:// 代理配置" />
|
||||
</FormItem>
|
||||
{chainEditMode ? (
|
||||
<div className="space-y-3 rounded-md border border-[var(--color-border)] p-3">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
链式代理会在启动实例时自动桥接为本地 SOCKS5,并以 <code className="px-1 bg-[var(--color-bg-secondary)] rounded">socks5://127.0.0.1:<port></code> 传给 Chromium。
|
||||
</p>
|
||||
<FormItem label="本地监听端口(可选)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={chainImportForm.localPort}
|
||||
onChange={e => setChainImportForm(prev => ({ ...prev, localPort: e.target.value }))}
|
||||
placeholder="留空自动分配"
|
||||
/>
|
||||
</FormItem>
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第一层 SOCKS5</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理地址" required>
|
||||
<Input value={chainImportForm.first.server} onChange={e => updateChainHop('first', 'server', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input type="number" min={1} max={65535} value={chainImportForm.first.port} onChange={e => updateChainHop('first', 'port', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input value={chainImportForm.first.username} onChange={e => updateChainHop('first', 'username', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input type="password" value={chainImportForm.first.password} onChange={e => updateChainHop('first', 'password', e.target.value)} />
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--color-text-primary)]">第二层 SOCKS5</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormItem label="代理地址" required>
|
||||
<Input value={chainImportForm.second.server} onChange={e => updateChainHop('second', 'server', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="代理端口" required>
|
||||
<Input type="number" min={1} max={65535} value={chainImportForm.second.port} onChange={e => updateChainHop('second', 'port', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="账号(可选)">
|
||||
<Input value={chainImportForm.second.username} onChange={e => updateChainHop('second', 'username', e.target.value)} />
|
||||
</FormItem>
|
||||
<FormItem label="密码(可选)">
|
||||
<Input type="password" value={chainImportForm.second.password} onChange={e => updateChainHop('second', 'password', e.target.value)} />
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FormItem label="代理配置">
|
||||
<Textarea value={editForm.proxyConfig} onChange={e => setEditForm(prev => ({ ...prev, proxyConfig: e.target.value }))} rows={10} placeholder="支持 Clash YAML、http://、https://、socks5://、chain+socks5:// 代理配置" />
|
||||
</FormItem>
|
||||
)}
|
||||
<FormItem label="DNS 服务器(可选)">
|
||||
<Textarea value={editForm.dnsServers} onChange={e => setEditForm(prev => ({ ...prev, dnsServers: e.target.value }))} rows={6}
|
||||
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`} />
|
||||
|
||||
Reference in New Issue
Block a user