mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-03 10:59:34 +00:00
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:
130
frontend/src/lib/xray/link-label.tsx
Normal file
130
frontend/src/lib/xray/link-label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user