Ver Fonte

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.
MHSanaei há 20 horas atrás
pai
commit
d0998c1d6d

+ 130 - 0
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<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>
+  );
+}

+ 9 - 77
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<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)} />

+ 11 - 2
frontend/src/pages/clients/ClientQrModal.tsx

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

+ 10 - 83
frontend/src/pages/sub/SubPage.tsx

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