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