feat: improve vmess structured editor toggling

This commit is contained in:
root
2026-03-26 00:15:23 +08:00
parent 5c0c07a912
commit 766ac225ef
5 changed files with 308 additions and 33 deletions
+277 -2
View File
@@ -125,6 +125,139 @@ const emptyPayload: ConfigNodePayload = {
password: '',
}
interface VmessFormFields {
ps: string
add: string
port: string
id: string
aid: string
scy: string
net: string
type: string
host: string
path: string
tls: string
sni: string
alpn: string
}
const emptyVmessFields: VmessFormFields = {
ps: '',
add: '',
port: '443',
id: '',
aid: '0',
scy: 'auto',
net: 'ws',
type: 'none',
host: '',
path: '',
tls: 'tls',
sni: '',
alpn: 'http/1.1',
}
interface ParsedShareLink {
name?: string
protocol?: string
valid: boolean
vmess?: VmessFormFields
}
function safeBase64Decode(value: string): string | null {
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, '')
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
return atob(padded)
} catch {
return null
}
}
function safeBase64Encode(value: string): string {
return btoa(unescape(encodeURIComponent(value)))
}
function parseVmessUri(uri: string): VmessFormFields | null {
const encoded = uri.slice('vmess://'.length).trim()
if (!encoded) return null
const decoded = safeBase64Decode(encoded)
if (!decoded) return null
try {
const payload = JSON.parse(decoded) as Record<string, unknown>
return {
ps: String(payload.ps || ''),
add: String(payload.add || ''),
port: String(payload.port || '443'),
id: String(payload.id || ''),
aid: String(payload.aid || '0'),
scy: String(payload.scy || 'auto'),
net: String(payload.net || 'ws'),
type: String(payload.type || 'none'),
host: String(payload.host || ''),
path: String(payload.path || ''),
tls: String(payload.tls || ''),
sni: String(payload.sni || ''),
alpn: String(payload.alpn || ''),
}
} catch {
return null
}
}
function buildVmessUri(fields: VmessFormFields): string {
const payload: Record<string, string> = {
v: '2',
ps: fields.ps.trim(),
add: fields.add.trim(),
port: String(fields.port || '443').trim(),
id: fields.id.trim(),
aid: String(fields.aid || '0').trim(),
scy: fields.scy.trim() || 'auto',
net: fields.net.trim() || 'ws',
type: fields.type.trim() || 'none',
host: fields.host.trim(),
path: fields.path.trim(),
tls: fields.tls.trim(),
}
if (fields.sni.trim()) payload.sni = fields.sni.trim()
if (fields.alpn.trim()) payload.alpn = fields.alpn.trim()
return `vmess://${safeBase64Encode(JSON.stringify(payload))}`
}
function parseGenericName(uri: string): string | undefined {
try {
const parsed = new URL(uri)
return parsed.hash ? decodeURIComponent(parsed.hash.slice(1)).trim() || undefined : undefined
} catch {
return undefined
}
}
function parseShareLink(uri: string): ParsedShareLink {
const trimmed = uri.trim()
if (!trimmed) return { valid: false }
const schemeMatch = trimmed.match(/^([a-z0-9+.-]+):\/\//i)
const protocol = schemeMatch?.[1]?.toLowerCase()
if (!protocol) return { valid: false }
if (protocol === 'vmess') {
const vmess = parseVmessUri(trimmed)
return {
protocol,
vmess: vmess || undefined,
name: vmess?.ps?.trim() || undefined,
valid: !!vmess,
}
}
return { protocol, name: parseGenericName(trimmed), valid: true }
}
// ---- Component ----
export default function ManagePanel() {
@@ -141,6 +274,10 @@ export default function ManagePanel() {
const [form, setForm] = useState<ConfigNodePayload>(emptyPayload)
const [formError, setFormError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [nameManuallyEdited, setNameManuallyEdited] = useState(false)
const [parseHint, setParseHint] = useState('')
const [vmessFields, setVmessFields] = useState<VmessFormFields>(emptyVmessFields)
const [vmessEditorOpen, setVmessEditorOpen] = useState(false)
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
@@ -320,10 +457,41 @@ export default function ManagePanel() {
}
}
const applyParsedLink = (value: string, allowAutoRename: boolean) => {
const parsed = parseShareLink(value)
setParseHint(
!value.trim()
? ''
: parsed.valid
? `已识别协议:${parsed.protocol || '未知'}${parsed.name ? `,建议名称:${parsed.name}` : ',未从链接中提取到名称'}`
: '未识别为有效的分享链接格式'
)
if (parsed.protocol === 'vmess' && parsed.vmess) {
setVmessFields(parsed.vmess)
setVmessEditorOpen(true)
} else if (!value.trim()) {
setVmessFields(emptyVmessFields)
setVmessEditorOpen(false)
}
setForm(prev => {
const next: ConfigNodePayload = { ...prev, uri: value }
if (allowAutoRename && parsed.name) {
next.name = parsed.name
}
return next
})
}
const openCreateModal = () => {
setEditingNode(null)
setForm(emptyPayload)
setFormError('')
setNameManuallyEdited(false)
setParseHint('')
setVmessFields(emptyVmessFields)
setVmessEditorOpen(false)
setModalOpen(true)
}
@@ -337,9 +505,31 @@ export default function ManagePanel() {
password: node.password || '',
})
setFormError('')
setNameManuallyEdited(false)
setVmessFields(emptyVmessFields)
setVmessEditorOpen(false)
applyParsedLink(node.uri, true)
setModalOpen(true)
}
const handleUriInputChange = (value: string) => {
applyParsedLink(value, !nameManuallyEdited)
}
const updateVmessFields = (patch: Partial<VmessFormFields>) => {
setVmessFields(prev => {
const next = { ...prev, ...patch }
const nextUri = buildVmessUri(next)
setForm(current => ({
...current,
uri: nextUri,
name: !nameManuallyEdited ? (next.ps || current.name) : current.name,
}))
setParseHint(`已识别协议:vmess${next.ps ? `,建议名称:${next.ps}` : ''}`)
return next
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.name.trim()) { setFormError('节点名称不能为空'); return }
@@ -958,7 +1148,14 @@ export default function ManagePanel() {
<input
type="text" className="input input-sm w-full" placeholder="节点名称"
value={form.name}
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
onChange={(e) => {
const value = e.target.value
setNameManuallyEdited(true)
setForm(f => ({ ...f, name: value }))
if (vmessEditorOpen) {
setVmessFields(prev => ({ ...prev, ps: value }))
}
}}
disabled={!!editingNode}
/>
</fieldset>
@@ -968,9 +1165,87 @@ export default function ManagePanel() {
type="text" className="input input-sm w-full font-mono text-xs"
placeholder="trojan://password@host:port?..."
value={form.uri}
onChange={(e) => setForm(f => ({ ...f, uri: 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">
<span>粘贴分享链接后会自动解析并回填字段;vmess 支持结构化编辑,新增/编辑都支持。</span>
{parseHint && <span className="text-info">{parseHint}</span>}
{parseShareLink(form.uri).protocol === 'vmess' && (
<button
type="button"
className="btn btn-xs btn-outline btn-info mt-1"
onClick={() => setVmessEditorOpen(open => !open)}
>
{vmessEditorOpen ? '收起结构化字段' : '展开结构化字段'}
</button>
)}
</div>
</fieldset>
{vmessEditorOpen && parseShareLink(form.uri).protocol === 'vmess' && (
<div className="rounded-xl border border-info/20 bg-info/5 p-4 mb-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-sm">VMess 结构化字段</div>
<div className="text-xs text-base-content/50">修改下面字段时,会自动重新生成上方 URI。</div>
</div>
<button type="button" className="btn btn-xs btn-ghost" onClick={() => setVmessEditorOpen(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={vmessFields.ps} onChange={(e) => updateVmessFields({ ps: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">地址</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.add} onChange={(e) => updateVmessFields({ add: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">端口</legend>
<input type="number" className="input input-sm w-full" value={vmessFields.port} onChange={(e) => updateVmessFields({ 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={vmessFields.id} onChange={(e) => updateVmessFields({ id: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">加密</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.scy} onChange={(e) => updateVmessFields({ scy: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">alterId</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.aid} onChange={(e) => updateVmessFields({ aid: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">传输</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.net} onChange={(e) => updateVmessFields({ net: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">type</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.type} onChange={(e) => updateVmessFields({ type: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Host</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.host} onChange={(e) => updateVmessFields({ host: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Path</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.path} onChange={(e) => updateVmessFields({ path: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">TLS</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.tls} onChange={(e) => updateVmessFields({ tls: e.target.value })} />
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">SNI</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.sni} onChange={(e) => updateVmessFields({ sni: e.target.value })} />
</fieldset>
<fieldset className="fieldset col-span-2">
<legend className="fieldset-legend">ALPN</legend>
<input type="text" className="input input-sm w-full" value={vmessFields.alpn} onChange={(e) => updateVmessFields({ alpn: e.target.value })} placeholder="如 http/1.1 或 h2,http/1.1" />
</fieldset>
</div>
</div>
)}
<fieldset className="fieldset mb-3">
<legend className="fieldset-legend">本地代理端口</legend>
<input
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Easy Proxies</title>
<script type="module" crossorigin src="/assets/index-DXv5BU-I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZdhzSdd.css">
<script type="module" crossorigin src="/assets/index-CT8A1vEX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GEylGL-C.css">
</head>
<body>
<div id="root"></div>