feat: improve vmess structured editor toggling
This commit is contained in:
@@ -125,6 +125,139 @@ const emptyPayload: ConfigNodePayload = {
|
|||||||
password: '',
|
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 ----
|
// ---- Component ----
|
||||||
|
|
||||||
export default function ManagePanel() {
|
export default function ManagePanel() {
|
||||||
@@ -141,6 +274,10 @@ export default function ManagePanel() {
|
|||||||
const [form, setForm] = useState<ConfigNodePayload>(emptyPayload)
|
const [form, setForm] = useState<ConfigNodePayload>(emptyPayload)
|
||||||
const [formError, setFormError] = useState('')
|
const [formError, setFormError] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
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
|
// Delete confirm
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
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 = () => {
|
const openCreateModal = () => {
|
||||||
setEditingNode(null)
|
setEditingNode(null)
|
||||||
setForm(emptyPayload)
|
setForm(emptyPayload)
|
||||||
setFormError('')
|
setFormError('')
|
||||||
|
setNameManuallyEdited(false)
|
||||||
|
setParseHint('')
|
||||||
|
setVmessFields(emptyVmessFields)
|
||||||
|
setVmessEditorOpen(false)
|
||||||
setModalOpen(true)
|
setModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,9 +505,31 @@ export default function ManagePanel() {
|
|||||||
password: node.password || '',
|
password: node.password || '',
|
||||||
})
|
})
|
||||||
setFormError('')
|
setFormError('')
|
||||||
|
setNameManuallyEdited(false)
|
||||||
|
setVmessFields(emptyVmessFields)
|
||||||
|
setVmessEditorOpen(false)
|
||||||
|
applyParsedLink(node.uri, true)
|
||||||
setModalOpen(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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.name.trim()) { setFormError('节点名称不能为空'); return }
|
if (!form.name.trim()) { setFormError('节点名称不能为空'); return }
|
||||||
@@ -958,7 +1148,14 @@ export default function ManagePanel() {
|
|||||||
<input
|
<input
|
||||||
type="text" className="input input-sm w-full" placeholder="节点名称"
|
type="text" className="input input-sm w-full" placeholder="节点名称"
|
||||||
value={form.name}
|
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}
|
disabled={!!editingNode}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -968,9 +1165,87 @@ export default function ManagePanel() {
|
|||||||
type="text" className="input input-sm w-full font-mono text-xs"
|
type="text" className="input input-sm w-full font-mono text-xs"
|
||||||
placeholder="trojan://password@host:port?..."
|
placeholder="trojan://password@host:port?..."
|
||||||
value={form.uri}
|
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>
|
</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">
|
<fieldset className="fieldset mb-3">
|
||||||
<legend className="fieldset-legend">本地代理端口</legend>
|
<legend className="fieldset-legend">本地代理端口</legend>
|
||||||
<input
|
<input
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Easy Proxies</title>
|
<title>Easy Proxies</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DXv5BU-I.js"></script>
|
<script type="module" crossorigin src="/assets/index-CT8A1vEX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CZdhzSdd.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-GEylGL-C.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user