Files
3x-ui/frontend/src/lib/xray/link-label.tsx
T
MHSanaei d0998c1d6d 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.
2026-06-03 02:18:40 +02:00

131 lines
4.4 KiB
TypeScript

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<string, string> = {
vless: 'Vless',
vmess: 'Vmess',
trojan: 'Trojan',
ss: 'Shadowsocks',
shadowsocks: 'Shadowsocks',
hysteria2: 'Hysteria2',
hy2: 'Hysteria2',
hysteria: 'Hysteria',
wireguard: 'WireGuard',
wg: 'WireGuard',
};
const PROTOCOL_COLORS: Record<string, string> = {
Vless: 'geekblue',
Vmess: 'blue',
Trojan: 'volcano',
Shadowsocks: 'purple',
Hysteria: 'magenta',
Hysteria2: 'magenta',
WireGuard: 'cyan',
};
const SECURITY_COLORS: Record<string, string> = {
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 (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
<Tag color={PROTOCOL_COLORS[parts.protocol]} style={TAG_STYLE}>{parts.protocol}</Tag>
{parts.network && <Tag color={TRANSPORT_COLOR} style={TAG_STYLE}>{parts.network}</Tag>}
{parts.security && (
<Tag color={SECURITY_COLORS[parts.security]} style={TAG_STYLE}>{parts.security}</Tag>
)}
</span>
);
}