Files
Ant-Browser/frontend/src/modules/browser/components/ProxyImportModal.tsx
T
UltrasoundX 1de3ac65be 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.
2026-04-24 17:16:24 +08:00

1036 lines
35 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import yaml from 'js-yaml'
import { Button, FormItem, Input, Modal, Select, Table, Textarea, toast } from '../../../shared/components'
import type { TableColumn } from '../../../shared/components/Table'
import type { BrowserProxy } from '../types'
import { fetchClashImportFromURL, saveBrowserProxies } from '../api'
interface ProxyImportModalProps {
open: boolean
onClose: () => void
existingProxies: BrowserProxy[]
groups: string[]
globalAutoRefreshEnabled?: boolean
globalRefreshIntervalM?: number
onImported?: (newProxies: BrowserProxy[]) => void | Promise<void>
}
interface ClashProxy {
name: string
type: string
server: string
port: number
[key: string]: any
}
type ProxyImportMode = 'clash' | 'direct' | 'chain'
interface DirectImportForm {
proxyName: string
protocol: 'http' | 'https' | 'socks5'
server: string
port: string
username: string
password: string
}
interface ChainHopForm {
server: string
port: string
username: string
password: string
}
interface ChainImportForm {
proxyName: string
localPort: string
first: ChainHopForm
second: ChainHopForm
}
const DIRECT_PROXY_PROTOCOL_OPTIONS = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' },
] as const
const INITIAL_DIRECT_IMPORT_FORM: DirectImportForm = {
proxyName: '',
protocol: 'http',
server: '',
port: '',
username: '',
password: '',
}
const INITIAL_CHAIN_IMPORT_FORM: ChainImportForm = {
proxyName: '',
localPort: '',
first: {
server: '',
port: '',
username: '',
password: '',
},
second: {
server: '',
port: '',
username: '',
password: '',
},
}
interface ImportCandidate {
proxyName: string
proxyConfig: string
}
interface ProxyDisplayInfo {
proxyId: string
proxyName: string
proxyConfig: string
groupName: string
type: string
server: string
port: number
}
const CHAIN_SOCKS5_PREFIX = 'chain+socks5://'
interface ChainSocks5HopConfig {
protocol: 'socks5'
server: string
port: number
username?: string
password?: string
}
interface ChainSocks5Config {
localPort?: number
first: ChainSocks5HopConfig
second: ChainSocks5HopConfig
}
function parseChainSocks5Config(proxyConfig: string): ChainSocks5Config | null {
const cfg = proxyConfig.trim()
if (!cfg.toLowerCase().startsWith(CHAIN_SOCKS5_PREFIX)) {
return null
}
const encoded = cfg.slice(CHAIN_SOCKS5_PREFIX.length)
if (!encoded) {
return null
}
const normalizeHop = (raw: unknown): ChainSocks5HopConfig | null => {
if (!raw || typeof raw !== 'object') return null
const hop = raw as Record<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()
try {
const u = new URL(cfg)
return { type: scheme, server: u.hostname, port: parseInt(u.port) || 0 }
} catch {
return { type: scheme, server: '-', port: 0 }
}
}
try {
const parsed = yaml.load(cfg) as ClashProxy[] | ClashProxy
const proxy = Array.isArray(parsed) ? parsed[0] : parsed
return { type: proxy?.type || '-', server: proxy?.server || '-', port: proxy?.port || 0 }
} catch {
return { type: '-', server: '-', port: 0 }
}
}
function proxyToYaml(proxy: ClashProxy): string {
return yaml.dump([proxy], { flowLevel: -1, lineWidth: -1 }).trim()
}
function quoteYamlScalar(value: string): string {
const v = value.trim()
if (!v) return "''"
return `'${v.replace(/'/g, "''")}'`
}
function normalizeImportedProxyArray(payload: unknown): ClashProxy[] | null {
const asArray = (input: unknown): ClashProxy[] => {
if (!Array.isArray(input)) return []
return input.filter((item): item is ClashProxy => !!item && typeof item === 'object')
}
if (Array.isArray(payload)) {
return asArray(payload)
}
if (!payload || typeof payload !== 'object') {
return null
}
const record = payload as Record<string, unknown>
if (Array.isArray(record.proxies)) {
return asArray(record.proxies)
}
if (Array.isArray(record.proxy)) {
return asArray(record.proxy)
}
if (Array.isArray(record.Proxy)) {
return asArray(record.Proxy)
}
return null
}
function normalizeLooseClashImportText(raw: string): string {
const normalizedNewline = raw.replace(//g, '').replace(/\r\n/g, '\n').trim()
if (!normalizedNewline) return normalizedNewline
const lines = normalizedNewline.split('\n')
const fixedLines = lines.map(line => {
const m = line.match(/^(\s*)-\s*([^,{][^,]*?)\s*,\s*(type\s*:.*)$/i)
if (!m) return line
const indent = m[1] || ''
const name = m[2] || ''
const tail = m[3] || ''
return `${indent}- { name: ${quoteYamlScalar(name)}, ${tail.trim()} }`
})
const hasProxiesRoot = fixedLines.some(line => /^\s*proxies\s*:/.test(line))
if (hasProxiesRoot) {
return fixedLines.join('\n')
}
const looksLikeProxyList = fixedLines.some(line => /^\s*-\s*/.test(line))
if (!looksLikeProxyList) {
return fixedLines.join('\n')
}
const indented = fixedLines.map(line => {
if (!line.trim()) return line
return ` ${line}`
})
return `proxies:\n${indented.join('\n')}`
}
function parseClashImportText(raw: string): ClashProxy[] {
const input = raw.trim()
if (!input) {
throw new Error('请输入 YAML 内容')
}
const attempts = [input]
const normalized = normalizeLooseClashImportText(input)
if (normalized && normalized !== input) {
attempts.push(normalized)
}
let lastError: unknown = null
for (const text of attempts) {
try {
const parsed = yaml.load(text)
const proxies = normalizeImportedProxyArray(parsed)
if (proxies) {
return proxies
}
} catch (error) {
lastError = error
}
}
if (lastError && typeof lastError === 'object' && lastError !== null && 'message' in lastError) {
throw new Error(String((lastError as { message?: string }).message || '解析失败'))
}
throw new Error('无效的 YAML 格式,需要包含 proxies 数组')
}
function normalizeDirectProxyConfig(raw: string): string {
const trimmed = raw.trim()
if (!trimmed) return ''
if (/^socket:\/\//i.test(trimmed)) {
return trimmed.replace(/^socket:\/\//i, 'socks5://')
}
if (/^socks:\/\//i.test(trimmed)) {
return trimmed.replace(/^socks:\/\//i, 'socks5://')
}
return trimmed
}
function resolveDirectProxyName(rawName: string, scheme: string, server: string, port: number, index: number, prefix: string): string {
const name = rawName.trim()
const fallbackName = server
? `${scheme.toUpperCase()}-${server}${port > 0 ? `:${port}` : ''}`
: `导入代理 ${index + 1}`
const finalName = name || fallbackName
return prefix ? `${prefix}-${finalName}` : finalName
}
function formatDirectProxyHost(raw: string): string {
const host = raw.trim()
if (!host) return ''
if (host.startsWith('[') && host.endsWith(']')) {
return host
}
return host.includes(':') ? `[${host}]` : host
}
function buildDirectImportCandidate(form: DirectImportForm): ImportCandidate {
const serverInput = form.server.trim()
if (!serverInput) {
throw new Error('请输入代理地址')
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(serverInput)) {
throw new Error('代理地址只需要填写主机名或 IP,不需要协议头')
}
const portInput = form.port.trim()
if (!portInput) {
throw new Error('请输入代理端口')
}
if (!/^\d+$/.test(portInput)) {
throw new Error('代理端口必须为数字')
}
const port = Number(portInput)
if (port < 1 || port > 65535) {
throw new Error('代理端口必须在 1-65535 之间')
}
const username = form.username.trim()
const password = form.password
if (password && !username) {
throw new Error('填写密码时请同时填写账号')
}
const auth = username
? `${encodeURIComponent(username)}${password ? `:${encodeURIComponent(password)}` : ''}@`
: ''
const rawConfig = `${form.protocol}://${auth}${formatDirectProxyHost(serverInput)}:${port}`
let parsedURL: URL
try {
parsedURL = new URL(rawConfig)
} catch {
throw new Error('请输入有效的代理地址')
}
if (!parsedURL.hostname) {
throw new Error('请输入有效的代理地址')
}
const normalizedConfig = normalizeDirectProxyConfig(parsedURL.toString()).replace(/\/$/, '')
const normalizedServer = parsedURL.hostname.replace(/^\[(.*)\]$/, '$1')
return {
proxyName: resolveDirectProxyName(form.proxyName, form.protocol, normalizedServer, port, 0, ''),
proxyConfig: normalizedConfig,
}
}
function buildChainImportCandidate(form: ChainImportForm): ImportCandidate {
const parseHop = (label: string, hop: ChainHopForm): ChainSocks5HopConfig => {
const server = hop.server.trim()
if (!server) {
throw new Error(`请输入${label}代理地址`)
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(server)) {
throw new Error(`${label}代理地址只需要填写主机名或 IP,不需要协议头`)
}
const portInput = hop.port.trim()
if (!portInput) {
throw new Error(`请输入${label}代理端口`)
}
if (!/^\d+$/.test(portInput)) {
throw new Error(`${label}代理端口必须为数字`)
}
const port = Number(portInput)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label}代理端口必须在 1-65535 之间`)
}
const username = hop.username.trim()
const password = hop.password
if (password && !username) {
throw new Error(`${label}填写密码时请同时填写账号`)
}
return {
protocol: 'socks5',
server,
port,
username: username || undefined,
password: password || undefined,
}
}
const localPortInput = form.localPort.trim()
if (localPortInput && !/^\d+$/.test(localPortInput)) {
throw new Error('本地监听端口必须为数字')
}
const localPort = localPortInput ? Number(localPortInput) : 0
if (localPortInput && (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)) {
throw new Error('本地监听端口必须在 1-65535 之间')
}
const payload: ChainSocks5Config = {
first: parseHop('第一层', form.first),
second: parseHop('第二层', form.second),
localPort: localPort > 0 ? localPort : undefined,
}
const encodedPayload = encodeURIComponent(JSON.stringify(payload))
const proxyConfig = `${CHAIN_SOCKS5_PREFIX}${encodedPayload}`
return {
proxyName: form.proxyName.trim() || `链式代理-${payload.first.server}-${payload.second.server}`,
proxyConfig,
}
}
function resolveImportedProxyName(proxy: ClashProxy, index: number, prefix: string): string {
const rawName = (proxy.name || '').trim() || `导入代理 ${index + 1}`
return prefix ? `${prefix}-${rawName}` : rawName
}
function buildImportCandidatesFromClash(parsedProxies: ClashProxy[], prefix: string): ImportCandidate[] {
return parsedProxies.map((proxy, index) => ({
proxyName: resolveImportedProxyName(proxy, index, prefix),
proxyConfig: proxyToYaml(proxy),
}))
}
function buildImportPreview(candidates: ImportCandidate[], groupName: string): ProxyDisplayInfo[] {
return candidates.map((candidate, index) => {
const info = parseProxyInfo(candidate.proxyConfig)
return {
proxyId: `preview-${index}`,
proxyName: candidate.proxyName,
proxyConfig: candidate.proxyConfig,
groupName,
type: info.type || '-',
server: info.server || '-',
port: info.port || 0,
}
})
}
function normalizeRefreshIntervalM(value: number): number {
if (!Number.isFinite(value)) return 0
if (value <= 0) return 0
if (value < 5) return 5
if (value > 24 * 60) return 24 * 60
return Math.round(value)
}
function normalizeSourceURL(sourceURL: string): string {
const raw = (sourceURL || '').trim()
if (!raw) return ''
try {
const parsed = new URL(raw)
parsed.hash = ''
return parsed.toString()
} catch {
return raw
}
}
function buildStableSourceID(sourceURL: string, sourceNamePrefix: string): string {
const key = `${normalizeSourceURL(sourceURL)}|||${sourceNamePrefix.trim()}`
let hash = 5381
for (let i = 0; i < key.length; i += 1) {
hash = ((hash << 5) + hash) ^ key.charCodeAt(i)
}
const unsigned = hash >>> 0
return `src-${unsigned.toString(36)}`
}
function resolveImportSourceID(list: BrowserProxy[], sourceURL: string, sourceNamePrefix: string): string {
const normalizedURL = normalizeSourceURL(sourceURL)
const normalizedPrefix = sourceNamePrefix.trim()
const existing = list.find(item =>
normalizeSourceURL(item.sourceUrl || '') === normalizedURL &&
(item.sourceNamePrefix || '').trim() === normalizedPrefix &&
(item.sourceId || '').trim() !== ''
)
if (existing?.sourceId?.trim()) {
return existing.sourceId.trim()
}
return buildStableSourceID(sourceURL, sourceNamePrefix)
}
function nextProxyID(): string {
return `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function createExistingProxyIDPicker(oldSourceProxies: BrowserProxy[]) {
const exactMap = new Map<string, BrowserProxy[]>()
const nameMap = new Map<string, BrowserProxy[]>()
oldSourceProxies.forEach(item => {
const exactKey = `${item.proxyName}|||${item.proxyConfig}`
const exactList = exactMap.get(exactKey) || []
exactList.push(item)
exactMap.set(exactKey, exactList)
const nameKey = item.proxyName
const nameList = nameMap.get(nameKey) || []
nameList.push(item)
nameMap.set(nameKey, nameList)
})
return (name: string, configText: string): string | null => {
const exactKey = `${name}|||${configText}`
const exactList = exactMap.get(exactKey)
if (exactList && exactList.length > 0) {
const item = exactList.shift()
if (item?.proxyId) return item.proxyId
}
const nameList = nameMap.get(name)
if (nameList && nameList.length > 0) {
const item = nameList.shift()
if (item?.proxyId) return item.proxyId
}
return null
}
}
export function ProxyImportModal({
open,
onClose,
existingProxies,
groups,
globalAutoRefreshEnabled = false,
globalRefreshIntervalM = 60,
onImported,
}: ProxyImportModalProps) {
const [importMode, setImportMode] = useState<ProxyImportMode>('clash')
const [importUrl, setImportUrl] = useState('')
const [importResolvedUrl, setImportResolvedUrl] = useState('')
const [importText, setImportText] = useState('')
const [importDnsServers, setImportDnsServers] = useState('')
const [importNamePrefix, setImportNamePrefix] = useState('')
const [importGroupName, setImportGroupName] = useState('')
const [directImportForm, setDirectImportForm] = useState<DirectImportForm>(() => ({ ...INITIAL_DIRECT_IMPORT_FORM }))
const [chainImportForm, setChainImportForm] = useState<ChainImportForm>(() => ({ ...INITIAL_CHAIN_IMPORT_FORM }))
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [previewList, setPreviewList] = useState<ProxyDisplayInfo[]>([])
const [importing, setImporting] = useState(false)
const [fetchingImportUrl, setFetchingImportUrl] = useState(false)
useEffect(() => {
if (open) return
setPreviewModalOpen(false)
}, [open])
const resetImportState = () => {
setImportMode('clash')
setImportUrl('')
setImportResolvedUrl('')
setImportText('')
setImportDnsServers('')
setImportNamePrefix('')
setImportGroupName('')
setDirectImportForm({ ...INITIAL_DIRECT_IMPORT_FORM })
setChainImportForm({ ...INITIAL_CHAIN_IMPORT_FORM })
setPreviewList([])
}
const handleImportModeChange = (nextMode: ProxyImportMode) => {
setImportMode(nextMode)
setImportResolvedUrl('')
if (nextMode !== 'clash') {
setImportUrl('')
setImportDnsServers('')
}
}
const updateChainHop = (hop: 'first' | 'second', field: keyof ChainHopForm, value: string) => {
setChainImportForm(prev => ({
...prev,
[hop]: {
...prev[hop],
[field]: value,
},
}))
}
const handleFetchImportURL = async () => {
const targetURL = importUrl.trim()
if (!targetURL) {
toast.error('请输入订阅 URL')
return
}
setFetchingImportUrl(true)
try {
const result = await fetchClashImportFromURL(targetURL)
const content = (result?.content || '').trim()
if (!content) {
throw new Error('订阅内容为空')
}
setImportResolvedUrl((result?.url || targetURL).trim())
setImportText(content)
if (!importDnsServers.trim() && typeof result?.dnsServers === 'string' && result.dnsServers.trim()) {
setImportDnsServers(result.dnsServers.trim())
}
if (!importGroupName.trim() && typeof result?.suggestedGroup === 'string' && result.suggestedGroup.trim()) {
setImportGroupName(result.suggestedGroup.trim())
}
toast.success(`URL 获取成功,检测到 ${Math.max(0, Number(result?.proxyCount || 0))} 个代理`)
} catch (error: any) {
setImportResolvedUrl('')
toast.error(error?.message || 'URL 获取失败')
} finally {
setFetchingImportUrl(false)
}
}
const handleParseImport = () => {
try {
const prefix = importNamePrefix.trim()
const candidates = importMode === 'clash'
? buildImportCandidatesFromClash(parseClashImportText(importText), prefix)
: importMode === 'direct'
? [buildDirectImportCandidate(directImportForm)]
: [buildChainImportCandidate(chainImportForm)]
if (!candidates.length) {
toast.error('未解析到可导入代理')
return
}
const preview = buildImportPreview(candidates, importGroupName.trim())
setPreviewList(preview)
setPreviewModalOpen(true)
} catch (error: any) {
toast.error(`解析失败: ${error?.message || '未知错误'}`)
}
}
const handleConfirmImport = async () => {
if (previewList.length === 0) {
toast.error('请至少保留 1 个代理后再导入')
return
}
setImporting(true)
try {
const sourceURL = importMode === 'clash' ? importResolvedUrl.trim() : ''
const isURLImport = !!sourceURL
const sourceNamePrefix = importMode === 'clash' ? importNamePrefix.trim() : ''
const sourceID = isURLImport ? resolveImportSourceID(existingProxies, sourceURL, sourceNamePrefix) : ''
const sourceAutoRefresh = isURLImport ? !!globalAutoRefreshEnabled : false
const sourceRefreshIntervalM = sourceAutoRefresh
? normalizeRefreshIntervalM(Number(globalRefreshIntervalM || 0))
: 0
const sourceLastRefreshAt = isURLImport ? new Date().toISOString() : ''
const oldSourceProxies = isURLImport
? existingProxies.filter(item => (item.sourceId || '').trim() === sourceID)
: []
const pickExistingID = createExistingProxyIDPicker(oldSourceProxies)
const newProxies: BrowserProxy[] = previewList.map((p) => ({
proxyId: pickExistingID(p.proxyName, p.proxyConfig) || nextProxyID(),
proxyName: p.proxyName,
proxyConfig: p.proxyConfig,
dnsServers: importMode === 'clash' ? importDnsServers.trim() || undefined : undefined,
groupName: importGroupName.trim() || undefined,
sourceId: sourceID || undefined,
sourceUrl: sourceURL || undefined,
sourceNamePrefix: sourceNamePrefix || undefined,
sourceAutoRefresh,
sourceRefreshIntervalM,
sourceLastRefreshAt: sourceLastRefreshAt || undefined,
}))
const allProxies = isURLImport
? existingProxies.filter(item => (item.sourceId || '').trim() !== sourceID).concat(newProxies)
: [...existingProxies, ...newProxies]
await saveBrowserProxies(allProxies)
await onImported?.(newProxies)
toast.success(`成功导入 ${newProxies.length} 个代理`)
setPreviewModalOpen(false)
resetImportState()
onClose()
} catch (error: any) {
toast.error(error?.message || '导入失败')
} finally {
setImporting(false)
}
}
const handleRemovePreviewProxy = (proxyId: string) => {
setPreviewList(prev => prev.filter(item => item.proxyId !== proxyId))
}
const canParseImport = importMode === 'clash'
? !!importText.trim()
: importMode === 'direct'
? !!directImportForm.server.trim() && !!directImportForm.port.trim()
: !!chainImportForm.first.server.trim() && !!chainImportForm.first.port.trim() && !!chainImportForm.second.server.trim() && !!chainImportForm.second.port.trim()
const previewColumns = useMemo<TableColumn<ProxyDisplayInfo>[]>(() => [
{ key: 'proxyName', title: '代理名称', width: '200px' },
{ key: 'type', title: '类型', width: '100px' },
{ key: 'server', title: '服务器', width: '200px' },
{ key: 'port', title: '端口', width: '100px', render: (val) => val || '-' },
{
key: 'actions',
title: '操作',
width: '96px',
render: (_, record) => (
<Button
size="sm"
variant="danger"
onClick={() => handleRemovePreviewProxy(record.proxyId)}
>
删除
</Button>
),
},
], [])
return (
<>
<Modal
open={open}
onClose={onClose}
title="导入代理配置"
width="600px"
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={fetchingImportUrl}>取消</Button>
<Button onClick={handleParseImport} disabled={fetchingImportUrl || !canParseImport}>解析</Button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
<Button
variant={importMode === 'clash' ? undefined : 'secondary'}
onClick={() => handleImportModeChange('clash')}
>
Clash 订阅 / YAML
</Button>
<Button
variant={importMode === 'direct' ? undefined : 'secondary'}
onClick={() => handleImportModeChange('direct')}
>
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'
: importMode === 'direct'
? '支持单条录入 HTTP / HTTPS / SOCKS5 代理,账号和密码均可留空,导入后直接生效,不走 Clash 桥接'
: '支持两层 SOCKS5 链式代理,导入后将由本地桥接生成 127.0.0.1 SOCKS5 供 Chromium 使用'}
</p>
{importMode === 'clash' && (
<>
<FormItem label="订阅 URL(可选)">
<div className="flex gap-2">
<Input
value={importUrl}
onChange={e => {
const next = e.target.value
setImportUrl(next)
if (importResolvedUrl.trim() && next.trim() !== importResolvedUrl.trim()) {
setImportResolvedUrl('')
}
}}
placeholder="https://example.com/clash/subscription"
className="flex-1"
/>
<Button
variant="secondary"
onClick={handleFetchImportURL}
loading={fetchingImportUrl}
disabled={!importUrl.trim()}
>
URL 获取
</Button>
</div>
{importResolvedUrl.trim() && (
<p className="text-xs text-[var(--color-success)] mt-1 break-all">
已绑定订阅:{importResolvedUrl}
</p>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1">获取成功后会自动回填 YAML 文本,并尝试自动填充 DNS 与建议分组</p>
</FormItem>
<Textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={12}
placeholder={`proxies:\n - name: vless-v6\n type: vless\n server: example.com\n port: 443\n uuid: your-uuid\n ...`}
/>
</>
)}
{importMode === 'direct' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormItem label="代理协议" required>
<Select
options={[...DIRECT_PROXY_PROTOCOL_OPTIONS]}
value={directImportForm.protocol}
onChange={e => setDirectImportForm(prev => ({ ...prev, protocol: e.target.value as DirectImportForm['protocol'] }))}
/>
</FormItem>
<FormItem label="代理名称(可选)">
<Input
value={directImportForm.proxyName}
onChange={e => setDirectImportForm(prev => ({ ...prev, proxyName: e.target.value }))}
placeholder="例如:香港节点"
/>
</FormItem>
<FormItem label="代理地址" required>
<Input
value={directImportForm.server}
onChange={e => setDirectImportForm(prev => ({ ...prev, server: e.target.value }))}
placeholder="例如:127.0.0.1 或 hk.example.com"
/>
</FormItem>
<FormItem label="代理端口" required>
<Input
type="number"
min={1}
max={65535}
value={directImportForm.port}
onChange={e => setDirectImportForm(prev => ({ ...prev, port: e.target.value }))}
placeholder="例如:1080"
/>
</FormItem>
<FormItem label="账号(可选)">
<Input
value={directImportForm.username}
onChange={e => setDirectImportForm(prev => ({ ...prev, username: e.target.value }))}
placeholder="留空则不使用认证"
/>
</FormItem>
<FormItem label="密码(可选)">
<Input
type="password"
value={directImportForm.password}
onChange={e => setDirectImportForm(prev => ({ ...prev, password: e.target.value }))}
placeholder="留空则不使用密码"
/>
</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}
onChange={e => setImportGroupName(e.target.value)}
placeholder="例如:香港、美国、机场A"
list="proxy-groups-datalist"
/>
{groups.length > 0 && (
<datalist id="proxy-groups-datalist">
{groups.map(g => <option key={g} value={g} />)}
</datalist>
)}
<p className="text-xs text-[var(--color-text-muted)] mt-1">填写后本次导入的代理将归入该分组,可按分组筛选</p>
</FormItem>
{importMode === 'clash' && (
<FormItem label="名称前缀(可选)">
<Input
value={importNamePrefix}
onChange={e => setImportNamePrefix(e.target.value)}
placeholder="例如:HK、US、机场A"
/>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
填写后代理名称将变为 <code className="px-1 bg-[var(--color-bg-secondary)] rounded">前缀-原名称</code>,留空则保持原名
</p>
</FormItem>
)}
{importMode === 'clash' && (
<FormItem label="批量 DNS 配置(可选)">
<Textarea value={importDnsServers} onChange={e => setImportDnsServers(e.target.value)} rows={5}
placeholder={`dns:\n enable: true\n nameserver:\n - 119.29.29.29\n - 223.5.5.5`} />
<p className="text-xs text-[var(--color-text-muted)] mt-1">留空则不配置 DNS,填写后将应用到本次导入的所有代理</p>
</FormItem>
)}
</div>
</Modal>
<Modal
open={previewModalOpen}
onClose={() => setPreviewModalOpen(false)}
title="确认导入以下代理"
width="700px"
footer={
<>
<Button variant="secondary" onClick={() => setPreviewModalOpen(false)}>返回修改</Button>
<Button onClick={handleConfirmImport} loading={importing} disabled={previewList.length === 0}>确认导入</Button>
</>
}
>
<div className="space-y-3">
{importMode === 'clash' && importDnsServers.trim() && (
<p className="text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-secondary)] px-3 py-2 rounded">已配置批量 DNS,将应用到以下所有代理</p>
)}
<Table columns={previewColumns} data={previewList} rowKey="proxyId" maxHeight="380px" emptyText="无代理数据" />
</div>
</Modal>
</>
)
}