Преглед на файлове

feat(clients): compact link + inbound rows in the info modal and table

ClientInfoModal — Copy URL section reskinned:
- Each link is a single row: [PROTOCOL] [remark] [copy] [QR]
  instead of a card with the raw 200-char URL printed inline
- Remark is parsed per-protocol — VMess pulls it from the
  base64-JSON `ps` field, the rest from the `#fragment`
- The row title strips the client email suffix so the same
  string isn't repeated three times in the modal; the QR
  popover still uses the full remark (it's the QR's own name
  for the download file)
- QR button opens an inline Popover with the existing QrPanel,
  size 220, destroyed on close
- Subscription section uses the same row layout (SUB / JSON
  tags, clickable subId, copy + QR actions)
- New per-protocol Tag colors so the protocol is identifiable
  at a glance

ClientInfoModal — Attached inbounds + ClientsPage table column:
- Chip format changed from `${remark} (${proto}:${port})` to
  just `${proto}:${port}` — when an admin attaches 5 inbounds
  to one client the remark was repeated 5 times and wrapped onto
  two lines
- Only the first inbound chip is shown; the rest collapse into
  a `+N` chip that opens a Popover with the full list (remark
  included). INBOUND_CHIP_LIMIT = 1
- Per-protocol Tag colors
- Tooltip on each chip shows the full `${remark}
  (${proto}:${port})`
- Table column pinned to width: 170 so the row doesn't reserve
  the old 300px of whitespace next to the compact chip

Comment row in the info table is always shown now (renders `-`
when unset) so the layout doesn't jump per-client.

VmessSecuritySchema gets a preprocess pass that maps legacy
`security: ""` (persisted on pre-enum-lock VMess inbounds) back
to `'auto'`. z.enum's `.default()` only fires on a missing
field, not on an empty string — without this, old rows fail
validation with "expected one of aes-128-gcm|chacha20-poly1305|
auto|none|zero". `z.infer` is taken from the raw enum so the
inferred type stays the union, not `unknown`.

i18n adds a `more` key (en-US + fa-IR) used by the overflow
chip label.
MHSanaei преди 19 часа
родител
ревизия
66f5026356

+ 61 - 0
frontend/src/pages/clients/ClientInfoModal.css

@@ -37,6 +37,24 @@
   display: flex;
   flex-wrap: wrap;
   gap: 4px;
+  align-items: center;
+}
+
+.chips-stack {
+  flex-direction: column;
+  align-items: flex-start;
+  max-width: 280px;
+  max-height: 280px;
+  overflow-y: auto;
+}
+
+.chip-more {
+  cursor: pointer;
+  user-select: none;
+}
+
+.chip-more:hover {
+  opacity: 0.85;
 }
 
 .link-panel {
@@ -84,3 +102,46 @@
   background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
   text-decoration-color: var(--ant-color-primary);
 }
+
+.link-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border: 1px solid var(--ant-color-border);
+  border-radius: 8px;
+  margin-bottom: 8px;
+}
+
+.link-row-tag {
+  margin: 0;
+  flex-shrink: 0;
+  font-weight: 600;
+  letter-spacing: 0.3px;
+}
+
+.link-row-title {
+  flex: 1;
+  min-width: 0;
+  font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.link-row-actions {
+  display: flex;
+  gap: 4px;
+  flex-shrink: 0;
+}
+
+.link-row-title-anchor {
+  color: var(--ant-color-primary);
+  text-decoration: underline;
+  text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 35%, transparent);
+  transition: text-decoration-color 120ms ease;
+}
+
+.link-row-title-anchor:hover {
+  text-decoration-color: var(--ant-color-primary);
+}

+ 319 - 166
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -1,13 +1,85 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Divider, Modal, Tag, Tooltip, message } from 'antd';
-import { CopyOutlined } from '@ant-design/icons';
+import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
+import { CopyOutlined, QrcodeOutlined } from '@ant-design/icons';
 
 import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import QrPanel from '@/pages/inbounds/QrPanel';
 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',
+  trojan: 'volcano',
+  shadowsocks: 'magenta',
+  hysteria: 'cyan',
+  hysteria2: 'green',
+  wireguard: 'gold',
+  http: 'purple',
+  mixed: 'lime',
+  tunnel: 'orange',
+};
+
+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();
+}
+
+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(atob(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;
@@ -107,192 +179,273 @@ export default function ClientInfoModal({
         footer={null}
         width={640}
         onCancel={() => onOpenChange(false)}
-    >
-      {client && (
-        <>
-          <table className="info-table block">
-            <tbody>
-              <tr>
-                <td>{t('pages.clients.online')}</td>
-                <td>
-                  {client.enable && isOnline
-                    ? <Tag color="green">{t('pages.clients.online')}</Tag>
-                    : <Tag>{t('pages.clients.offline')}</Tag>}
-                  <span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
-                </td>
-              </tr>
-              <tr>
-                <td>{t('status')}</td>
-                <td>
-                  <Tag color={client.enable ? 'green' : 'default'}>
-                    {client.enable ? t('enabled') : t('disabled')}
-                  </Tag>
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.clients.email')}</td>
-                <td>
-                  {client.email
-                    ? <Tag color="green">{client.email}</Tag>
-                    : <Tag color="red">{t('none')}</Tag>}
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.clients.subId')}</td>
-                <td>
-                  <Tag className="info-large-tag">{client.subId || '-'}</Tag>
-                  {client.subId && (
-                    <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
-                  )}
-                </td>
-              </tr>
-              {client.uuid && (
+      >
+        {client && (
+          <>
+            <table className="info-table block">
+              <tbody>
+                <tr>
+                  <td>{t('pages.clients.online')}</td>
+                  <td>
+                    {client.enable && isOnline
+                      ? <Tag color="green">{t('pages.clients.online')}</Tag>
+                      : <Tag>{t('pages.clients.offline')}</Tag>}
+                    <span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
+                  </td>
+                </tr>
+                <tr>
+                  <td>{t('status')}</td>
+                  <td>
+                    <Tag color={client.enable ? 'green' : 'default'}>
+                      {client.enable ? t('enabled') : t('disabled')}
+                    </Tag>
+                  </td>
+                </tr>
+                <tr>
+                  <td>{t('pages.clients.email')}</td>
+                  <td>
+                    {client.email
+                      ? <Tag color="green">{client.email}</Tag>
+                      : <Tag color="red">{t('none')}</Tag>}
+                  </td>
+                </tr>
+                <tr>
+                  <td>{t('pages.clients.subId')}</td>
+                  <td>
+                    <Tag className="info-large-tag">{client.subId || '-'}</Tag>
+                    {client.subId && (
+                      <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
+                    )}
+                  </td>
+                </tr>
+                {client.uuid && (
+                  <tr>
+                    <td>{t('pages.clients.uuid')}</td>
+                    <td>
+                      <Tag className="info-large-tag">{client.uuid}</Tag>
+                      <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
+                    </td>
+                  </tr>
+                )}
+                {client.password && (
+                  <tr>
+                    <td>{t('password')}</td>
+                    <td>
+                      <Tag className="info-large-tag">{client.password}</Tag>
+                      <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
+                    </td>
+                  </tr>
+                )}
+                {client.auth && (
+                  <tr>
+                    <td>{t('pages.clients.auth')}</td>
+                    <td>
+                      <Tag className="info-large-tag">{client.auth}</Tag>
+                      <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
+                    </td>
+                  </tr>
+                )}
                 <tr>
-                  <td>{t('pages.clients.uuid')}</td>
+                  <td>{t('pages.clients.flow')}</td>
                   <td>
-                    <Tag className="info-large-tag">{client.uuid}</Tag>
-                    <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
+                    {client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
                   </td>
                 </tr>
-              )}
-              {client.password && (
                 <tr>
-                  <td>{t('password')}</td>
+                  <td>{t('pages.inbounds.traffic')}</td>
                   <td>
-                    <Tag className="info-large-tag">{client.password}</Tag>
-                    <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
+                    <Tag>
+                      ↑ {SizeFormatter.sizeFormat(traffic?.up || 0)}
+                      {' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)}
+                    </Tag>
+                    <span className="hint">
+                      {SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
+                    </span>
                   </td>
                 </tr>
-              )}
-              {client.auth && (
                 <tr>
-                  <td>{t('pages.clients.auth')}</td>
+                  <td>{t('remained')}</td>
                   <td>
-                    <Tag className="info-large-tag">{client.auth}</Tag>
-                    <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
+                    {remaining < 0
+                      ? <Tag color="purple">∞</Tag>
+                      : <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
                   </td>
                 </tr>
-              )}
-              <tr>
-                <td>{t('pages.clients.flow')}</td>
-                <td>
-                  {client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.inbounds.traffic')}</td>
-                <td>
-                  <Tag>
-                    ↑ {SizeFormatter.sizeFormat(traffic?.up || 0)}
-                    {' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)}
-                  </Tag>
-                  <span className="hint">
-                    {SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
-                  </span>
-                </td>
-              </tr>
-              <tr>
-                <td>{t('remained')}</td>
-                <td>
-                  {remaining < 0
-                    ? <Tag color="purple">∞</Tag>
-                    : <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.inbounds.expireDate')}</td>
-                <td>
-                  {!client.expiryTime
-                    ? <Tag color="purple">∞</Tag>
-                    : <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
-                  {(client.expiryTime ?? 0) > 0 && (
-                    <span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
-                  )}
-                </td>
-              </tr>
-              <tr>
-                <td>{t('pages.clients.ipLimit')}</td>
-                <td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
-              </tr>
-              <tr>
-                <td>{t('pages.inbounds.createdAt')}</td>
-                <td><Tag>{dateLabel(client.createdAt)}</Tag></td>
-              </tr>
-              <tr>
-                <td>{t('pages.inbounds.updatedAt')}</td>
-                <td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
-              </tr>
-              {client.comment && (
                 <tr>
-                  <td>{t('pages.clients.comment')}</td>
-                  <td><Tag className="info-large-tag">{client.comment}</Tag></td>
+                  <td>{t('pages.inbounds.expireDate')}</td>
+                  <td>
+                    {!client.expiryTime
+                      ? <Tag color="purple">∞</Tag>
+                      : <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
+                    {(client.expiryTime ?? 0) > 0 && (
+                      <span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
+                    )}
+                  </td>
+                </tr>
+                <tr>
+                  <td>{t('pages.clients.ipLimit')}</td>
+                  <td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
+                </tr>
+                <tr>
+                  <td>{t('pages.inbounds.createdAt')}</td>
+                  <td><Tag>{dateLabel(client.createdAt)}</Tag></td>
+                </tr>
+                <tr>
+                  <td>{t('pages.inbounds.updatedAt')}</td>
+                  <td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
                 </tr>
-              )}
-              <tr>
-                <td>{t('pages.clients.attachedInbounds')}</td>
-                <td>
-                  <div className="chips">
-                    {(client.inboundIds || []).map((id) => {
-                      const ib = inboundsById[id];
+                {client.comment && (
+                  <tr>
+                    <td>{t('pages.clients.comment')}</td>
+                    <td><Tag className="info-large-tag">{client.comment}</Tag></td>
+                  </tr>
+                )}
+                <tr>
+                  <td>{t('pages.clients.attachedInbounds')}</td>
+                  <td>
+                    {(() => {
+                      const ids = client.inboundIds || [];
+                      if (ids.length === 0) return <span className="hint">—</span>;
+                      const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
+                      const overflow = ids.slice(INBOUND_CHIP_LIMIT);
+                      const inboundChip = (id: number, compact: boolean) => {
+                        const ib = inboundsById[id];
+                        const proto = (ib?.protocol || '').toLowerCase();
+                        const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
+                        const fullLabel = ib
+                          ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})`
+                          : `#${id}`;
+                        const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
+                        return (
+                          <Tooltip key={id} title={fullLabel}>
+                            <Tag color={color}>{compact ? compactLabel : fullLabel}</Tag>
+                          </Tooltip>
+                        );
+                      };
                       return (
-                        <Tag key={id} color="blue">
-                          {ib ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})` : `#${id}`}
-                        </Tag>
+                        <div className="chips">
+                          {visible.map((id) => inboundChip(id, true))}
+                          {overflow.length > 0 && (
+                            <Popover
+                              trigger="click"
+                              placement="bottomRight"
+                              content={
+                                <div className="chips chips-stack">
+                                  {overflow.map((id) => inboundChip(id, false))}
+                                </div>
+                              }
+                            >
+                              <Tag color="default" className="chip-more">
+                                +{overflow.length} {t('more') !== 'more' ? t('more') : 'more'}
+                              </Tag>
+                            </Popover>
+                          )}
+                        </div>
                       );
-                    })}
-                    {(!client.inboundIds || client.inboundIds.length === 0) && (
-                      <span className="hint">—</span>
-                    )}
-                  </div>
-                </td>
-              </tr>
-            </tbody>
-          </table>
+                    })()}
+                  </td>
+                </tr>
+              </tbody>
+            </table>
 
-          {links.length > 0 && (
-            <>
-              <Divider>{t('pages.inbounds.copyLink')}</Divider>
-              {links.map((link, idx) => (
-                <div key={idx} className="link-panel">
-                  <div className="link-panel-header">
-                    <Tag color="green">{`${t('pages.clients.link')} ${idx + 1}`}</Tag>
-                    <Tooltip title={t('copy')}>
-                      <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
-                    </Tooltip>
-                  </div>
-                  <code className="link-panel-text">{link}</code>
-                </div>
-              ))}
-            </>
-          )}
+            {links.length > 0 && (
+              <>
+                <Divider>{t('pages.inbounds.copyLink')}</Divider>
+                {links.map((link, idx) => {
+                  const meta = parseLinkMeta(link);
+                  const qrRemark = meta.remark || `${t('pages.clients.link')} ${idx + 1}`;
+                  const rowTitle = trimEmail(meta.remark, client.email)
+                    || `${t('pages.clients.link')} ${idx + 1}`;
+                  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>
+                      <div className="link-row-actions">
+                        <Tooltip title={t('copy')}>
+                          <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
+                        </Tooltip>
+                        <Popover
+                          trigger="click"
+                          placement="left"
+                          destroyOnHidden
+                          content={<QrPanel value={link} remark={qrRemark} size={220} />}
+                        >
+                          <Tooltip title={t('pages.clients.qrCode')}>
+                            <Button size="small" icon={<QrcodeOutlined />} />
+                          </Tooltip>
+                        </Popover>
+                      </div>
+                    </div>
+                  );
+                })}
+              </>
+            )}
 
-          {showSubscription && subLink && (
-            <>
-              <Divider>{t('subscription.title')}</Divider>
-              <div className="link-panel">
-                <div className="link-panel-header">
-                  <Tag color="green">{t('subscription.title')}</Tag>
-                  <Tooltip title={t('copy')}>
-                    <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
-                  </Tooltip>
-                </div>
-                <a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
-              </div>
-              {subJsonLink && (
-                <div className="link-panel">
-                  <div className="link-panel-header">
-                    <Tag color="green">JSON</Tag>
+            {showSubscription && subLink && (
+              <>
+                <Divider>{t('subscription.title')}</Divider>
+                <div className="link-row">
+                  <Tag color="green" className="link-row-tag">SUB</Tag>
+                  <a
+                    href={subLink}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="link-row-title link-row-title-anchor"
+                    title={subLink}
+                  >
+                    {client.subId}
+                  </a>
+                  <div className="link-row-actions">
                     <Tooltip title={t('copy')}>
-                      <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
+                      <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
                     </Tooltip>
+                    <Popover
+                      trigger="click"
+                      placement="left"
+                      destroyOnHidden
+                      content={<QrPanel value={subLink} remark={`${client.email} — ${t('subscription.title')}`} size={220} />}
+                    >
+                      <Tooltip title={t('pages.clients.qrCode')}>
+                        <Button size="small" icon={<QrcodeOutlined />} />
+                      </Tooltip>
+                    </Popover>
                   </div>
-                  <a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
                 </div>
-              )}
-            </>
-          )}
-        </>
-      )}
+                {subJsonLink && (
+                  <div className="link-row">
+                    <Tag color="purple" className="link-row-tag">JSON</Tag>
+                    <a
+                      href={subJsonLink}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="link-row-title link-row-title-anchor"
+                      title={subJsonLink}
+                    >
+                      {client.subId}
+                    </a>
+                    <div className="link-row-actions">
+                      <Tooltip title={t('copy')}>
+                        <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
+                      </Tooltip>
+                      <Popover
+                        trigger="click"
+                        placement="left"
+                        destroyOnHidden
+                        content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
+                      >
+                        <Tooltip title={t('pages.clients.qrCode')}>
+                          <Button size="small" icon={<QrcodeOutlined />} />
+                        </Tooltip>
+                      </Popover>
+                    </div>
+                  </div>
+                )}
+              </>
+            )}
+          </>
+        )}
       </Modal>
     </>
   );

+ 50 - 3
frontend/src/pages/clients/ClientsPage.tsx

@@ -72,6 +72,20 @@ interface FilterState {
   inboundFilter?: number;
 }
 
+const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
+  vless: 'blue',
+  vmess: 'geekblue',
+  trojan: 'volcano',
+  shadowsocks: 'magenta',
+  hysteria: 'cyan',
+  hysteria2: 'green',
+  wireguard: 'gold',
+  http: 'purple',
+  mixed: 'lime',
+  tunnel: 'orange',
+};
+const INBOUND_CHIP_LIMIT = 1;
+
 function readFilterState(): FilterState {
   try {
     const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
@@ -531,12 +545,45 @@ export default function ClientsPage() {
       sortableCol({
         title: t('pages.clients.attachedInbounds'),
         key: 'inboundIds',
+        width: 170,
         render: (_v, record) => {
           const ids = record.inboundIds || [];
           if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
-          return ids.map((id) => (
-            <Tag key={id} color="blue" style={{ margin: 2 }}>{inboundLabel(id)}</Tag>
-          ));
+          const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
+          const overflow = ids.slice(INBOUND_CHIP_LIMIT);
+          const chip = (id: number, compact: boolean) => {
+            const ib = inboundsById[id];
+            const proto = (ib?.protocol || '').toLowerCase();
+            const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
+            const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
+            return (
+              <Tooltip key={id} title={inboundLabel(id)}>
+                <Tag color={color} style={{ margin: 2 }}>
+                  {compact ? compactLabel : inboundLabel(id)}
+                </Tag>
+              </Tooltip>
+            );
+          };
+          return (
+            <>
+              {visible.map((id) => chip(id, true))}
+              {overflow.length > 0 && (
+                <Popover
+                  trigger="click"
+                  placement="bottomRight"
+                  content={
+                    <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
+                      {overflow.map((id) => chip(id, false))}
+                    </div>
+                  }
+                >
+                  <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+                    +{overflow.length}
+                  </Tag>
+                </Popover>
+              )}
+            </>
+          );
         },
       }, 'inboundIds'),
       sortableCol({

+ 11 - 2
frontend/src/schemas/protocols/inbound/vmess.ts

@@ -1,13 +1,22 @@
 import { z } from 'zod';
 
-export const VmessSecuritySchema = z.enum([
+const VmessSecurityEnum = z.enum([
   'aes-128-gcm',
   'chacha20-poly1305',
   'auto',
   'none',
   'zero',
 ]);
-export type VmessSecurity = z.infer<typeof VmessSecuritySchema>;
+
+// Legacy rows persisted `security: ""` (especially on VMess inbounds
+// created before the enum was nailed down). Preprocess maps the empty
+// string back to the documented default so existing data parses cleanly
+// — subsequent writes serialize the normalized value.
+export const VmessSecuritySchema = z.preprocess(
+  (val) => (val === '' ? 'auto' : val),
+  VmessSecurityEnum,
+);
+export type VmessSecurity = z.infer<typeof VmessSecurityEnum>;
 
 export const VmessClientSchema = z.object({
   id: z.uuid(),

+ 1 - 0
web/translation/en-US.json

@@ -11,6 +11,7 @@
   "update": "Update",
   "copy": "Copy",
   "copied": "Copied",
+  "more": "more",
   "download": "Download",
   "remark": "Remark",
   "enable": "Enabled",

+ 1 - 0
web/translation/fa-IR.json

@@ -11,6 +11,7 @@
   "update": "به‌روزرسانی",
   "copy": "کپی",
   "copied": "کپی شد",
+  "more": "بیشتر",
   "download": "دانلود",
   "remark": "نام",
   "enable": "فعال",