Compare commits

...
1 Commits
Author SHA1 Message Date
xiaolongxia b3ff896fdf feat(frontend): add VLESS structured editor like VMess 2026-04-13 14:20:36 +08:00
2 changed files with 232 additions and 3 deletions
-1
View File
@@ -3,7 +3,6 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: ghcr.io/jasonwong1991/easy_proxies:latest
container_name: easy-proxies container_name: easy-proxies
restart: unless-stopped restart: unless-stopped
# 方式一:使用主机网络模式(推荐,支持端口自动重分配) # 方式一:使用主机网络模式(推荐,支持端口自动重分配)
+232 -2
View File
@@ -157,11 +157,52 @@ const emptyVmessFields: VmessFormFields = {
alpn: 'http/1.1', alpn: 'http/1.1',
} }
interface VlessFormFields {
name: string
add: string
port: string
id: string
encryption: string
flow: string
net: string
type: string
host: string
path: string
security: string
sni: string
alpn: string
fp: string
pbk: string
sid: string
allowInsecure: string
}
const emptyVlessFields: VlessFormFields = {
name: '',
add: '',
port: '443',
id: '',
encryption: 'none',
flow: '',
net: 'tcp',
type: 'none',
host: '',
path: '',
security: 'tls',
sni: '',
alpn: '',
fp: '',
pbk: '',
sid: '',
allowInsecure: '',
}
interface ParsedShareLink { interface ParsedShareLink {
name?: string name?: string
protocol?: string protocol?: string
valid: boolean valid: boolean
vmess?: VmessFormFields vmess?: VmessFormFields
vless?: VlessFormFields
} }
function safeBase64Decode(value: string): string | null { function safeBase64Decode(value: string): string | null {
@@ -228,6 +269,63 @@ function buildVmessUri(fields: VmessFormFields): string {
return `vmess://${safeBase64Encode(JSON.stringify(payload))}` return `vmess://${safeBase64Encode(JSON.stringify(payload))}`
} }
function parseVlessUri(uri: string): VlessFormFields | null {
try {
const parsed = new URL(uri)
const uuid = parsed.username
if (!uuid) return null
const server = parsed.hostname
const port = parsed.port || '443'
const query = parsed.searchParams
const name = parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : ''
return {
name,
add: server,
port,
id: uuid,
encryption: query.get('encryption') || 'none',
flow: query.get('flow') || '',
net: query.get('type') || 'tcp',
type: query.get('type') || 'none',
host: query.get('host') || '',
path: query.get('path') || '',
security: query.get('security') || 'tls',
sni: query.get('sni') || '',
alpn: query.get('alpn') || '',
fp: query.get('fp') || '',
pbk: query.get('pbk') || '',
sid: query.get('sid') || '',
allowInsecure: query.get('allowInsecure') || '',
}
} catch {
return null
}
}
function buildVlessUri(fields: VlessFormFields): string {
const params = new URLSearchParams()
params.set('encryption', fields.encryption || 'none')
if (fields.flow) params.set('flow', fields.flow)
if (fields.net && fields.net !== 'tcp') params.set('type', fields.net)
if (fields.host) params.set('host', fields.host)
if (fields.path) params.set('path', fields.path)
if (fields.security && fields.security !== 'tls') {
params.set('security', fields.security)
}
if (fields.sni) params.set('sni', fields.sni)
if (fields.alpn) params.set('alpn', fields.alpn)
if (fields.fp) params.set('fp', fields.fp)
if (fields.pbk) params.set('pbk', fields.pbk)
if (fields.sid) params.set('sid', fields.sid)
if (fields.allowInsecure) params.set('allowInsecure', fields.allowInsecure)
const encodedParams = params.toString()
const uri = `vless://${fields.id}@${fields.add}:${fields.port || '443'}?${encodedParams}#${encodeURIComponent(fields.name)}`
return uri
}
function parseGenericName(uri: string): string | undefined { function parseGenericName(uri: string): string | undefined {
try { try {
const parsed = new URL(uri) const parsed = new URL(uri)
@@ -255,6 +353,16 @@ function parseShareLink(uri: string): ParsedShareLink {
} }
} }
if (protocol === 'vless') {
const vless = parseVlessUri(trimmed)
return {
protocol,
vless: vless || undefined,
name: vless?.name?.trim() || undefined,
valid: !!vless,
}
}
return { protocol, name: parseGenericName(trimmed), valid: true } return { protocol, name: parseGenericName(trimmed), valid: true }
} }
@@ -278,6 +386,8 @@ export default function ManagePanel() {
const [parseHint, setParseHint] = useState('') const [parseHint, setParseHint] = useState('')
const [vmessFields, setVmessFields] = useState<VmessFormFields>(emptyVmessFields) const [vmessFields, setVmessFields] = useState<VmessFormFields>(emptyVmessFields)
const [vmessEditorOpen, setVmessEditorOpen] = useState(false) const [vmessEditorOpen, setVmessEditorOpen] = useState(false)
const [vlessFields, setVlessFields] = useState<VlessFormFields>(emptyVlessFields)
const [vlessEditorOpen, setVlessEditorOpen] = useState(false)
// Delete confirm // Delete confirm
const [deleteTarget, setDeleteTarget] = useState<string | null>(null) const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
@@ -470,9 +580,18 @@ export default function ManagePanel() {
if (parsed.protocol === 'vmess' && parsed.vmess) { if (parsed.protocol === 'vmess' && parsed.vmess) {
setVmessFields(parsed.vmess) setVmessFields(parsed.vmess)
setVmessEditorOpen(true) setVmessEditorOpen(true)
setVlessFields(emptyVlessFields)
setVlessEditorOpen(false)
} else if (parsed.protocol === 'vless' && parsed.vless) {
setVlessFields(parsed.vless)
setVlessEditorOpen(true)
setVmessFields(emptyVmessFields)
setVmessEditorOpen(false)
} else if (!value.trim()) { } else if (!value.trim()) {
setVmessFields(emptyVmessFields) setVmessFields(emptyVmessFields)
setVmessEditorOpen(false) setVmessEditorOpen(false)
setVlessFields(emptyVlessFields)
setVlessEditorOpen(false)
} }
setForm(prev => { setForm(prev => {
@@ -492,6 +611,8 @@ export default function ManagePanel() {
setParseHint('') setParseHint('')
setVmessFields(emptyVmessFields) setVmessFields(emptyVmessFields)
setVmessEditorOpen(false) setVmessEditorOpen(false)
setVlessFields(emptyVlessFields)
setVlessEditorOpen(false)
setModalOpen(true) setModalOpen(true)
} }
@@ -508,6 +629,8 @@ export default function ManagePanel() {
setNameManuallyEdited(false) setNameManuallyEdited(false)
setVmessFields(emptyVmessFields) setVmessFields(emptyVmessFields)
setVmessEditorOpen(false) setVmessEditorOpen(false)
setVlessFields(emptyVlessFields)
setVlessEditorOpen(false)
applyParsedLink(node.uri, true) applyParsedLink(node.uri, true)
setModalOpen(true) setModalOpen(true)
} }
@@ -530,6 +653,20 @@ export default function ManagePanel() {
}) })
} }
const updateVlessFields = (patch: Partial<VlessFormFields>) => {
setVlessFields(prev => {
const next = { ...prev, ...patch }
const nextUri = buildVlessUri(next)
setForm(current => ({
...current,
uri: nextUri,
name: !nameManuallyEdited ? (next.name || current.name) : current.name,
}))
setParseHint(`已识别协议:vless${next.name ? `,建议名称:${next.name}` : ''}`)
return next
})
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!form.name.trim()) { setFormError('节点名称不能为空'); return } if (!form.name.trim()) { setFormError('节点名称不能为空'); return }
@@ -1155,6 +1292,9 @@ export default function ManagePanel() {
if (vmessEditorOpen) { if (vmessEditorOpen) {
setVmessFields(prev => ({ ...prev, ps: value })) setVmessFields(prev => ({ ...prev, ps: value }))
} }
if (vlessEditorOpen) {
setVlessFields(prev => ({ ...prev, name: value }))
}
}} }}
disabled={!!editingNode} disabled={!!editingNode}
/> />
@@ -1168,7 +1308,7 @@ export default function ManagePanel() {
onChange={(e) => handleUriInputChange(e.target.value)} onChange={(e) => handleUriInputChange(e.target.value)}
/> />
<div className="label text-base-content/50 mt-1 flex flex-col items-start gap-1"> <div className="label text-base-content/50 mt-1 flex flex-col items-start gap-1">
<span>粘贴分享链接后会自动解析并回填字段;vmess 支持结构化编辑,新增/编辑都支持。</span> <span>粘贴分享链接后会自动解析并回填字段;vmess/vless 支持结构化编辑,新增/编辑都支持。</span>
{parseHint && <span className="text-info">{parseHint}</span>} {parseHint && <span className="text-info">{parseHint}</span>}
{parseShareLink(form.uri).protocol === 'vmess' && ( {parseShareLink(form.uri).protocol === 'vmess' && (
<button <button
@@ -1176,7 +1316,16 @@ export default function ManagePanel() {
className="btn btn-xs btn-outline btn-info mt-1" className="btn btn-xs btn-outline btn-info mt-1"
onClick={() => setVmessEditorOpen(open => !open)} onClick={() => setVmessEditorOpen(open => !open)}
> >
{vmessEditorOpen ? '收起结构化字段' : '展开结构化字段'} {vmessEditorOpen ? '收起 VMess 结构化字段' : '展开 VMess 结构化字段'}
</button>
)}
{parseShareLink(form.uri).protocol === 'vless' && (
<button
type="button"
className="btn btn-xs btn-outline btn-warning mt-1"
onClick={() => setVlessEditorOpen(open => !open)}
>
{vlessEditorOpen ? '收起 VLESS 结构化字段' : '展开 VLESS 结构化字段'}
</button> </button>
)} )}
</div> </div>
@@ -1246,6 +1395,87 @@ export default function ManagePanel() {
</div> </div>
</div> </div>
)} )}
{vlessEditorOpen && parseShareLink(form.uri).protocol === 'vless' && (
<div className="rounded-xl border border-warning/20 bg-warning/5 p-4 mb-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-sm">VLESS 结构化字段</div>
<div className="text-xs text-base-content/50">修改下面字段时,会自动重新生成上方 URI。</div>
</div>
<button type="button" className="btn btn-xs btn-ghost" onClick={() => setVlessEditorOpen(false)}>收起</button>
</div>
<div className="grid grid-cols-2 gap-3">
<fieldset className="fieldset">
<legend className="fieldset-legend">节点名称</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.name} onChange={(e) => updateVlessFields({ name: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">地址</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.add} onChange={(e) => updateVlessFields({ add: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">端口</legend>
<input type="number" className="input input-sm w-full" value={vlessFields.port} onChange={(e) => updateVlessFields({ port: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">UUID</legend>
<input type="text" className="input input-sm w-full font-mono text-xs" value={vlessFields.id} onChange={(e) => updateVlessFields({ id: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">加密</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.encryption} onChange={(e) => updateVlessFields({ encryption: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Flow</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.flow} onChange={(e) => updateVlessFields({ flow: e.target.value })} placeholder="如 xtls-rprx-vision" />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">传输</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.net} onChange={(e) => updateVlessFields({ net: e.target.value })} placeholder="tcp/ws/grpc/httpupgrade" />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">type</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.type} onChange={(e) => updateVlessFields({ type: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Host</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.host} onChange={(e) => updateVlessFields({ host: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Path</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.path} onChange={(e) => updateVlessFields({ path: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Security</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.security} onChange={(e) => updateVlessFields({ security: e.target.value })} placeholder="tls/reality/none" />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">SNI</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.sni} onChange={(e) => updateVlessFields({ sni: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">ALPN</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.alpn} onChange={(e) => updateVlessFields({ alpn: e.target.value })} placeholder="如 http/1.1 或 h2,http/1.1" />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Fingerprint</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.fp} onChange={(e) => updateVlessFields({ fp: e.target.value })} placeholder="如 chrome/firefox" />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Public Key (reality)</legend>
<input type="text" className="input input-sm w-full font-mono text-xs" value={vlessFields.pbk} onChange={(e) => updateVlessFields({ pbk: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Short ID (reality)</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.sid} onChange={(e) => updateVlessFields({ sid: e.target.value })} />
</fieldset>
<fieldset className="fieldset col-span-2">
<legend className="fieldset-legend">Allow Insecure</legend>
<input type="text" className="input input-sm w-full" value={vlessFields.allowInsecure} onChange={(e) => updateVlessFields({ allowInsecure: e.target.value })} placeholder="1/true 或留空" />
</fieldset>
</div>
</div>
)}
<fieldset className="fieldset mb-3"> <fieldset className="fieldset mb-3">
<legend className="fieldset-legend">本地代理端口</legend> <legend className="fieldset-legend">本地代理端口</legend>
<input <input