feat: improve vmess structured editor toggling
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user