From d0998c1d6d643099b8fdc6d6c51fc4b8d5cfdb78 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 3 Jun 2026 02:18:40 +0200 Subject: [PATCH] feat(links): richer share-link labels across QR, client info and sub views Show colored protocol/transport/security tags followed by the inbound remark and port for each share link in the client QR modal, client info modal and subscription page. The client email and the traffic/expiry decorations are stripped from the remark so only the inbound remark and port remain. Consolidate the duplicated per-page parseLinkMeta/trimEmail/PROTOCOL_COLORS into a shared lib/xray/link-label.tsx (parseLinkParts, LinkTags, linkMetaText) so the colours and the email/stats stripping stay identical across all three surfaces. --- frontend/src/lib/xray/link-label.tsx | 130 ++++++++++++++++++ .../src/pages/clients/ClientInfoModal.tsx | 86 ++---------- frontend/src/pages/clients/ClientQrModal.tsx | 13 +- frontend/src/pages/sub/SubPage.tsx | 93 ++----------- 4 files changed, 160 insertions(+), 162 deletions(-) create mode 100644 frontend/src/lib/xray/link-label.tsx diff --git a/frontend/src/lib/xray/link-label.tsx b/frontend/src/lib/xray/link-label.tsx new file mode 100644 index 00000000..6e743927 --- /dev/null +++ b/frontend/src/lib/xray/link-label.tsx @@ -0,0 +1,130 @@ +import { Tag } from 'antd'; +import { Base64 } from '@/utils'; + +/* Shared parsing + rendering for the "protocol / transport / security" + labels shown above share links in the QR modal, the client info modal + and the subscription page. Keeping it in one place means the colour + scheme and the email/stats stripping stay identical across all three. */ + +export interface LinkParts { + protocol: string; + network: string; + security: string; + remark: string; + port: string; +} + +const PROTOCOL_LABELS: Record = { + vless: 'Vless', + vmess: 'Vmess', + trojan: 'Trojan', + ss: 'Shadowsocks', + shadowsocks: 'Shadowsocks', + hysteria2: 'Hysteria2', + hy2: 'Hysteria2', + hysteria: 'Hysteria', + wireguard: 'WireGuard', + wg: 'WireGuard', +}; + +const PROTOCOL_COLORS: Record = { + Vless: 'geekblue', + Vmess: 'blue', + Trojan: 'volcano', + Shadowsocks: 'purple', + Hysteria: 'magenta', + Hysteria2: 'magenta', + WireGuard: 'cyan', +}; + +const SECURITY_COLORS: Record = { + TLS: 'green', + XTLS: 'green', + REALITY: 'purple', +}; + +const TRANSPORT_COLOR = 'gold'; + +const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' }; + +/* Strip the client email and the optional traffic/expiry decorations the + panel appends to a remark (e.g. "5.23GB📊", "30D⏳", "⛔️N/A") together + with any separator chars left dangling, so the label shows just the + inbound remark. The email is known from the client record, so it can be + removed even though its position in the composed remark depends on the + panel's remark-model settings. */ +function cleanRemark(remark: string, email: string): string { + let r = remark + .replace(/⛔️?N\/A/gu, '') + .replace(/[0-9][0-9A-Za-z.,]*[📊⏳]/gu, ''); + if (email) { + const esc = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + r = r.replace(new RegExp(`[\\s\\-_.|,@]*${esc}`, 'g'), ''); + } + return r.replace(/^[\s\-_.|,@]+|[\s\-_.|,@]+$/gu, '').trim(); +} + +/* Pull protocol, transport, security plus the inbound remark and port out + of a share link. vless/trojan carry network+security as `type`/`security` + query params and the remark in the URL hash; vmess packs them into the + base64 JSON as `net`/`tls`/`ps`/`port`. Returns null when the scheme is + unknown or the payload can't be parsed, so callers fall back to "Link N". */ +export function parseLinkParts(link: string, email = ''): LinkParts | null { + const trimmed = link.trim(); + const scheme = /^([a-z0-9]+):\/\//i.exec(trimmed)?.[1]?.toLowerCase() ?? ''; + if (!scheme) return null; + const protocol = PROTOCOL_LABELS[scheme] ?? scheme.charAt(0).toUpperCase() + scheme.slice(1); + let network = ''; + let security = ''; + let remark = ''; + let port = ''; + if (scheme === 'vmess') { + try { + const json = JSON.parse(Base64.decode(trimmed.slice('vmess://'.length).split('#')[0])) as { + net?: string; + tls?: string; + ps?: string; + port?: string | number; + }; + network = json.net ?? ''; + security = json.tls ?? ''; + remark = typeof json.ps === 'string' ? json.ps : ''; + port = json.port != null ? String(json.port) : ''; + } catch { /* unparseable payload, fall back to protocol only */ } + } else { + try { + const url = new URL(trimmed); + network = url.searchParams.get('type') ?? ''; + security = url.searchParams.get('security') ?? ''; + port = url.port; + const hash = url.hash.replace(/^#/, ''); + try { remark = decodeURIComponent(hash); } catch { remark = hash; } + } catch { /* not URL-shaped, fall back to protocol only */ } + } + if (security === 'none') security = ''; + return { + protocol, + network: network.toUpperCase(), + security: security.toUpperCase(), + remark: cleanRemark(remark, email), + port, + }; +} + +/* The inbound remark and port joined as they appear after the tags, e.g. + "22:10452". Either piece may be empty. */ +export function linkMetaText(parts: LinkParts): string { + return [parts.remark, parts.port].filter(Boolean).join(':'); +} + +export function LinkTags({ parts }: { parts: LinkParts }) { + return ( + + {parts.protocol} + {parts.network && {parts.network}} + {parts.security && ( + {parts.security} + )} + + ); +} diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index d35337e5..a6cd026d 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -7,18 +7,10 @@ import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import { isPostQuantumLink } from '@/lib/xray/inbound-link'; +import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label'; import { QrPanel } from '@/pages/inbounds/qr'; import './ClientInfoModal.css'; -const PROTOCOL_COLORS: Record = { - VLESS: 'blue', - VMESS: 'geekblue', - TROJAN: 'volcano', - SS: 'magenta', - HYSTERIA: 'cyan', - HY2: 'green', -}; - const INBOUND_PROTOCOL_COLORS: Record = { vless: 'blue', vmess: 'geekblue', @@ -34,64 +26,6 @@ const INBOUND_PROTOCOL_COLORS: Record = { const INBOUND_CHIP_LIMIT = 1; -// 3x-ui's genRemark concatenates inbound remark + client email (and an -// optional extra) using a configurable separator. The email half is -// redundant in the row title — the modal already names the client by -// email at the top — so trimEmail strips it back out for the row only. -// The original remark is preserved for the QR (it's the QR's own name). -function trimEmail(remark: string, email: string): string { - if (!email) return remark; - const e = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return remark - .replace(new RegExp(`[-_.\\s|]+${e}$`), '') - .replace(new RegExp(`^${e}[-_.\\s|]+`), '') - .trim(); -} - -// Decode a base64 string as UTF-8. atob() returns a binary string where -// each char holds one raw byte (Latin-1 interpretation), which mangles -// any multi-byte UTF-8 sequence in the payload — most commonly the -// emoji decorations the panel embeds in remarks (📊, ⏳). -function base64DecodeUtf8(b64: string): string { - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new TextDecoder('utf-8').decode(bytes); -} - -function parseLinkMeta(link: string): { protocol: string; remark: string } { - const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link); - const scheme = schemeMatch?.[1]?.toLowerCase() ?? ''; - const protocolMap: Record = { - vless: 'VLESS', - vmess: 'VMESS', - trojan: 'TROJAN', - ss: 'SS', - hysteria: 'HYSTERIA', - hysteria2: 'HY2', - hy2: 'HY2', - }; - const protocol = protocolMap[scheme] ?? scheme.toUpperCase() ?? 'LINK'; - - let remark = ''; - if (scheme === 'vmess') { - try { - const body = link.slice('vmess://'.length).split('#')[0]; - const json = JSON.parse(base64DecodeUtf8(body)) as { ps?: unknown }; - if (typeof json?.ps === 'string') remark = json.ps; - } catch { /* fall through to fragment parsing */ } - } - if (!remark) { - const hashIdx = link.indexOf('#'); - if (hashIdx >= 0) { - const raw = link.slice(hashIdx + 1); - try { remark = decodeURIComponent(raw); } - catch { remark = raw; } - } - } - return { protocol, remark }; -} - interface SubSettings { enable: boolean; subURI: string; @@ -419,19 +353,17 @@ export default function ClientInfoModal({ <> {t('pages.inbounds.copyLink')} {links.map((link, idx) => { - const meta = parseLinkMeta(link); - const rowTitle = trimEmail(meta.remark, client.email) - || `${t('pages.clients.link')} ${idx + 1}`; - const qrRemark = client.email - ? `${rowTitle}-${client.email}` - : (meta.remark || `${t('pages.clients.link')} ${idx + 1}`); + const parts = parseLinkParts(link, client.email); + const fallback = `${t('pages.clients.link')} ${idx + 1}`; + const rowTitle = (parts && linkMetaText(parts)) || fallback; + const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle; const canQr = !isPostQuantumLink(link); return (
- - {meta.protocol} - - {rowTitle} + {parts + ? + : LINK} + {rowTitle}