4 Sitoutukset f6d4358f9e ... e63cde8fcb

Tekijä SHA1 Viesti Päivämäärä
  MHSanaei e63cde8fcb feat(settings): move the remark model control to the subscription tab 19 tuntia sitten
  MHSanaei d0998c1d6d feat(links): richer share-link labels across QR, client info and sub views 20 tuntia sitten
  MHSanaei ccfd04219b fix(panel): register /groups SPA route so hard refresh returns index.html 20 tuntia sitten
  MHSanaei b08fc0c963 fix(clients): keep reverse tag clearable and preserve flow on attach 22 tuntia sitten

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

+ 1 - 1
frontend/src/models/setting.ts

@@ -34,7 +34,7 @@ export class AllSetting {
   subSupportUrl = '';
   subProfileUrl = '';
   subAnnounce = '';
-  subEnableRouting = true;
+  subEnableRouting = false;
   subRoutingRules = '';
   subListen = '';
   subPort = 2096;

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

+ 0 - 49
frontend/src/pages/settings/GeneralTab.tsx

@@ -5,7 +5,6 @@ import {
   Input,
   InputNumber,
   Select,
-  Space,
   Switch,
 } from 'antd';
 import type { AllSetting } from '@/models/setting';
@@ -23,8 +22,6 @@ interface GeneralTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
-const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
-const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
 const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [
   { name: 'Gregorian (Standard)', value: 'gregorian' },
   { name: 'Jalalian (شمسی)', value: 'jalalian' },
@@ -57,30 +54,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
     return () => { cancelled = true; };
   }, []);
 
-  const remarkModel = useMemo(() => {
-    const rm = allSetting.remarkModel || '';
-    return rm.length > 1 ? rm.substring(1).split('') : [];
-  }, [allSetting.remarkModel]);
-
-  const remarkSeparator = useMemo(() => {
-    const rm = allSetting.remarkModel || '-';
-    return rm.length > 1 ? rm.charAt(0) : '-';
-  }, [allSetting.remarkModel]);
-
-  const remarkSample = useMemo(() => {
-    const parts = remarkModel.map((k) => REMARK_MODELS[k]);
-    return parts.length === 0 ? '' : parts.join(remarkSeparator);
-  }, [remarkModel, remarkSeparator]);
-
-  function setRemarkModel(parts: string[]) {
-    updateSetting({ remarkModel: remarkSeparator + parts.join('') });
-  }
-
-  function setRemarkSeparator(sep: string) {
-    const tail = (allSetting.remarkModel || '-').substring(1);
-    updateSetting({ remarkModel: sep + tail });
-  }
-
   const ldapInboundTagList = useMemo(() => {
     const csv = allSetting.ldapInboundTags || '';
     return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
@@ -115,28 +88,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
         label: t('pages.settings.panelSettings'),
         children: (
           <>
-            <SettingListItem
-              paddings="small"
-              title={t('pages.settings.remarkModel')}
-              description={<>{t('pages.settings.sampleRemark')}: <i>#{remarkSample}</i></>}
-            >
-              <Space.Compact style={{ width: '100%' }}>
-                <Select
-                  mode="multiple"
-                  value={remarkModel}
-                  onChange={setRemarkModel}
-                  style={{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }}
-                  options={Object.entries(REMARK_MODELS).map(([k, l]) => ({ value: k, label: l }))}
-                />
-                <Select
-                  value={remarkSeparator}
-                  onChange={setRemarkSeparator}
-                  style={{ width: '20%' }}
-                  options={REMARK_SEPARATORS.map((s) => ({ value: s, label: s }))}
-                />
-              </Space.Compact>
-            </SettingListItem>
-
             <SettingListItem paddings="small" title={t('pages.settings.panelListeningIP')} description={t('pages.settings.panelListeningIPDesc')}>
               <Input value={allSetting.webListen} onChange={(e) => updateSetting({ webListen: e.target.value })} />
             </SettingListItem>

+ 68 - 1
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,9 +1,14 @@
-import { Collapse, Divider, Input, InputNumber, Switch } from 'antd';
+import { useMemo } from 'react';
+import { Collapse, Divider, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { useTranslation } from 'react-i18next';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { sanitizePath, normalizePath } from './uriPath';
 
+const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
+const REMARK_SAMPLES: Record<string, string> = { i: 'Germany', e: 'john', o: 'Relay' };
+const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
+
 interface SubscriptionGeneralTabProps {
   allSetting: AllSetting;
   updateSetting: (patch: Partial<AllSetting>) => void;
@@ -12,6 +17,30 @@ interface SubscriptionGeneralTabProps {
 export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
   const { t } = useTranslation();
 
+  const remarkModel = useMemo(() => {
+    const rm = allSetting.remarkModel || '';
+    return rm.length > 1 ? rm.substring(1).split('') : [];
+  }, [allSetting.remarkModel]);
+
+  const remarkSeparator = useMemo(() => {
+    const rm = allSetting.remarkModel || '-';
+    return rm.length > 1 ? rm.charAt(0) : '-';
+  }, [allSetting.remarkModel]);
+
+  const remarkSample = useMemo(() => {
+    const parts = remarkModel.map((k) => REMARK_SAMPLES[k]);
+    return parts.length === 0 ? '' : parts.join(remarkSeparator);
+  }, [remarkModel, remarkSeparator]);
+
+  function setRemarkModel(parts: string[]) {
+    updateSetting({ remarkModel: remarkSeparator + parts.join('') });
+  }
+
+  function setRemarkSeparator(sep: string) {
+    const tail = (allSetting.remarkModel || '-').substring(1);
+    updateSetting({ remarkModel: sep + tail });
+  }
+
   return (
     <Collapse defaultActiveKey="1" items={[
       {
@@ -68,6 +97,44 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
               <Switch checked={allSetting.subEmailInRemark} onChange={(v) => updateSetting({ subEmailInRemark: v })} />
             </SettingListItem>
 
+            <SettingListItem
+              paddings="small"
+              title={t('pages.settings.remarkModel')}
+              description={
+                <>
+                  {t('pages.settings.sampleRemark')}:{' '}
+                  <span
+                    style={{
+                      fontFamily: 'monospace',
+                      padding: '1px 6px',
+                      borderRadius: 4,
+                      border: '1px solid var(--ant-color-border)',
+                      background: 'var(--ant-color-fill-tertiary)',
+                      whiteSpace: 'pre',
+                    }}
+                  >
+                    {remarkSample ? `#${remarkSample}` : '—'}
+                  </span>
+                </>
+              }
+            >
+              <Space.Compact style={{ width: '100%' }}>
+                <Select
+                  mode="multiple"
+                  value={remarkModel}
+                  onChange={setRemarkModel}
+                  style={{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }}
+                  options={Object.entries(REMARK_MODELS).map(([k, l]) => ({ value: k, label: l }))}
+                />
+                <Select
+                  value={remarkSeparator}
+                  onChange={setRemarkSeparator}
+                  style={{ width: '20%' }}
+                  options={REMARK_SEPARATORS.map((s) => ({ value: s, label: s === ' ' ? '␣' : s }))}
+                />
+              </Space.Compact>
+            </SettingListItem>
+
             <Divider>{t('pages.settings.subTitle')}</Divider>
 
             <SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>

+ 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, 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?.remark || 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}

+ 1 - 0
web/controller/xui.go

@@ -39,6 +39,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
 	g.GET("/", a.panelSPA)
 	g.GET("/inbounds", a.panelSPA)
 	g.GET("/clients", a.panelSPA)
+	g.GET("/groups", a.panelSPA)
 	g.GET("/nodes", a.panelSPA)
 	g.GET("/settings", a.panelSPA)
 	g.GET("/xray", a.panelSPA)

+ 17 - 0
web/service/client.go

@@ -730,6 +730,18 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		}
 	}
 
+	reverseStr := ""
+	if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
+		if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
+			reverseStr = string(b)
+		}
+	}
+	if err := database.GetDB().Model(&model.ClientRecord{}).
+		Where("id = ?", id).
+		Update("reverse", reverseStr).Error; err != nil {
+		return needRestart, err
+	}
+
 	if err := database.GetDB().Model(&model.ClientRecord{}).
 		Where("id = ?", id).
 		UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
@@ -805,6 +817,11 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
 	}
 
 	clientWire := existing.ToClient()
+	flow, ffErr := s.EffectiveFlow(nil, id)
+	if ffErr != nil {
+		return false, ffErr
+	}
+	clientWire.Flow = flow
 	clientWire.UpdatedAt = time.Now().UnixMilli()
 
 	needRestart := false

+ 76 - 0
web/service/client_flow_isolation_test.go

@@ -179,3 +179,79 @@ func TestEffectiveFlow_ClearedFlowStaysCleared(t *testing.T) {
 		t.Errorf("EffectiveFlow = %q, want empty (cleared flow must stay cleared)", got)
 	}
 }
+
+func TestAttach_PreservesVisionFlowWhenCanonicalColumnZeroed(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const email = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c111"
+	const sub = "subvision000001"
+	const vision = "xtls-rprx-vision"
+	const realityStream = `{"network":"tcp","security":"reality"}`
+
+	svc := ClientService{}
+	source := model.Client{Email: email, ID: uid, SubID: sub, Enable: true, Flow: vision}
+
+	reality1 := &model.Inbound{
+		Tag: "vless-reality-1", Enable: true, Port: 42001, Protocol: model.VLESS,
+		StreamSettings: realityStream,
+		Settings:       clientsSettings(t, []model.Client{source}),
+	}
+	if err := db.Create(reality1).Error; err != nil {
+		t.Fatalf("create reality1: %v", err)
+	}
+	reality2 := &model.Inbound{
+		Tag: "vless-reality-2", Enable: true, Port: 42002, Protocol: model.VLESS,
+		StreamSettings: realityStream, Settings: `{"clients":[]}`,
+	}
+	if err := db.Create(reality2).Error; err != nil {
+		t.Fatalf("create reality2: %v", err)
+	}
+	wsTls := &model.Inbound{
+		Tag: "vless-ws", Enable: true, Port: 42003, Protocol: model.VLESS,
+		StreamSettings: `{"network":"ws","security":"tls"}`, Settings: `{"clients":[]}`,
+	}
+	if err := db.Create(wsTls).Error; err != nil {
+		t.Fatalf("create ws: %v", err)
+	}
+
+	if err := svc.SyncInbound(nil, reality1.Id, []model.Client{clientWithInboundFlow(source, reality1)}); err != nil {
+		t.Fatalf("SyncInbound(reality1): %v", err)
+	}
+
+	rec, err := svc.GetRecordByEmail(nil, email)
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Update("flow", "").Error; err != nil {
+		t.Fatalf("zero canonical flow: %v", err)
+	}
+
+	inboundSvc := &InboundService{}
+	if _, err := svc.Attach(inboundSvc, rec.Id, []int{reality2.Id, wsTls.Id}); err != nil {
+		t.Fatalf("Attach: %v", err)
+	}
+
+	reality2List, err := svc.ListForInbound(nil, reality2.Id)
+	if err != nil {
+		t.Fatalf("ListForInbound(reality2): %v", err)
+	}
+	if len(reality2List) != 1 || reality2List[0].Flow != vision {
+		t.Errorf("attached flow-capable inbound must inherit Vision via EffectiveFlow (#4834), got %#v", reality2List)
+	}
+
+	wsList, err := svc.ListForInbound(nil, wsTls.Id)
+	if err != nil {
+		t.Fatalf("ListForInbound(ws): %v", err)
+	}
+	if len(wsList) != 1 || wsList[0].Flow != "" {
+		t.Errorf("attached non-flow inbound must not receive Vision flow, got %#v", wsList)
+	}
+}

+ 2 - 2
web/service/setting.go

@@ -61,7 +61,7 @@ var defaultValueMap = map[string]string{
 	"subSupportUrl":               "",
 	"subProfileUrl":               "",
 	"subAnnounce":                 "",
-	"subEnableRouting":            "true",
+	"subEnableRouting":            "false",
 	"subRoutingRules":             "",
 	"subListen":                   "",
 	"subPort":                     "2096",
@@ -76,7 +76,7 @@ var defaultValueMap = map[string]string{
 	"subURI":                      "",
 	"subJsonPath":                 "/json/",
 	"subJsonURI":                  "",
-	"subClashEnable":              "true",
+	"subClashEnable":              "false",
 	"subClashPath":                "/clash/",
 	"subClashURI":                 "",
 	"subJsonFragment":             "",