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.
This commit is contained in:
MHSanaei
2026-06-03 02:18:40 +02:00
parent ccfd04219b
commit d0998c1d6d
4 changed files with 160 additions and 162 deletions

View File

@@ -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<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>
);
}

View File

@@ -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<string, string> = {
VLESS: 'blue',
VMESS: 'geekblue',
TROJAN: 'volcano',
SS: 'magenta',
HYSTERIA: 'cyan',
HY2: 'green',
};
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
vless: 'blue',
vmess: 'geekblue',
@@ -34,64 +26,6 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
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<string, string> = {
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({
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{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 (
<div key={idx} className="link-row">
<Tag color={PROTOCOL_COLORS[meta.protocol] ?? 'default'} className="link-row-tag">
{meta.protocol}
</Tag>
<span className="link-row-title" title={qrRemark}>{rowTitle}</span>
{parts
? <LinkTags parts={parts} />
: <Tag className="link-row-tag">LINK</Tag>}
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Collapse, Modal, Spin } from 'antd';
import { HttpUtil } from '@/utils';
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
import { QrPanel } from '@/pages/inbounds/qr';
import type { ClientRecord } from '@/hooks/useClients';
@@ -75,7 +76,7 @@ export default function ClientQrModal({
const [activeKey, setActiveKey] = useState<string[]>([]);
const items = useMemo(() => {
const out: { key: string; label: string; children: React.ReactNode }[] = [];
const out: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [];
if (subLink) {
out.push({
key: 'sub',
@@ -91,9 +92,17 @@ export default function ClientQrModal({
});
}
links.forEach((link, idx) => {
const parts = parseLinkParts(link, client?.email ?? '');
const meta = parts ? linkMetaText(parts) : '';
const label: React.ReactNode = parts ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<LinkTags parts={parts} />
{meta && <span style={{ opacity: 0.6, fontSize: 12 }}>({meta})</span>}
</span>
) : `${t('pages.clients.link')} ${idx + 1}`;
out.push({
key: `l${idx}`,
label: `${t('pages.clients.link')} ${idx + 1}`,
label,
children: (
<QrPanel
value={link}

View File

@@ -32,6 +32,7 @@ import {
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import SubUsageSummary from './SubUsageSummary';
@@ -71,72 +72,6 @@ const isActive = (() => {
return true;
})();
const PROTOCOL_COLORS: Record<string, string> = {
VLESS: 'blue',
VMESS: 'geekblue',
TROJAN: 'volcano',
SS: 'magenta',
HYSTERIA: 'cyan',
HY2: 'green',
};
// Same idea as ClientInfoModal.trimEmail — strip the client email
// suffix from the remark so the row title isn't ugly twice.
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, idx: number): { protocol: string; remark: string } {
const fallback = `Link ${idx + 1}`;
if (!link) return { protocol: 'LINK', remark: fallback };
const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
const protocolMap: Record<string, string> = {
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 */ }
}
if (!remark) {
const hashIdx = link.indexOf('#');
if (hashIdx >= 0 && hashIdx + 1 < link.length) {
const raw = link.slice(hashIdx + 1);
try { remark = decodeURIComponent(raw); }
catch { remark = raw; }
}
}
return { protocol, remark: remark || fallback };
}
export default function SubPage() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
@@ -459,20 +394,17 @@ export default function SubPage() {
<Divider>{t('pages.inbounds.copyLink')}</Divider>
<div className="links-section">
{links.map((link, idx) => {
const meta = parseLinkMeta(link, idx);
const rowEmail = linkEmails[idx] || '';
const rowTitle = trimEmail(meta.remark, rowEmail) || meta.remark;
const qrLabel = rowEmail ? `${rowTitle}-${rowEmail}` : meta.remark;
const parts = parseLinkParts(link, linkEmails[idx] || '');
const fallback = `Link ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback;
const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle;
const canQr = !isPostQuantumLink(link);
return (
<div key={link} className="sub-link-row">
<Tag
color={PROTOCOL_COLORS[meta.protocol] ?? 'default'}
className="sub-link-tag"
>
{meta.protocol}
</Tag>
<span className="sub-link-title" title={meta.remark}>
{parts
? <LinkTags parts={parts} />
: <Tag className="sub-link-tag">LINK</Tag>}
<span className="sub-link-title" title={rowTitle}>
{rowTitle}
</span>
<div className="sub-link-actions">
@@ -490,12 +422,7 @@ export default function SubPage() {
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag
color={PROTOCOL_COLORS[meta.protocol] ?? 'default'}
className="qr-tag"
>
{qrLabel}
</Tag>
<Tag className="qr-tag">{qrLabel}</Tag>
<QRCode
value={link}
size={220}