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:
UltrasoundX
2026-04-24 17:16:24 +08:00
parent a722d897b4
commit 1de3ac65be
8 changed files with 2340 additions and 130 deletions
+75
View File
@@ -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)
+22 -3
View File
@@ -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
View File
@@ -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:&lt;port&gt;</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`} />