From 66f50263561cec2cdc2ee030433db983136923de Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 27 May 2026 02:36:44 +0200 Subject: [PATCH] feat(clients): compact link + inbound rows in the info modal and table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/pages/clients/ClientInfoModal.css | 61 +++ .../src/pages/clients/ClientInfoModal.tsx | 489 ++++++++++++------ frontend/src/pages/clients/ClientsPage.tsx | 53 +- .../src/schemas/protocols/inbound/vmess.ts | 13 +- web/translation/en-US.json | 1 + web/translation/fa-IR.json | 1 + 6 files changed, 445 insertions(+), 173 deletions(-) diff --git a/frontend/src/pages/clients/ClientInfoModal.css b/frontend/src/pages/clients/ClientInfoModal.css index 1320fd98..24234bf1 100644 --- a/frontend/src/pages/clients/ClientInfoModal.css +++ b/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); +} diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index eea4fe85..5e755fd1 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/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 = { + VLESS: 'blue', + VMESS: 'geekblue', + TROJAN: 'volcano', + SS: 'magenta', + HYSTERIA: 'cyan', + HY2: 'green', +}; + +const INBOUND_PROTOCOL_COLORS: Record = { + 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 = { + 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 && ( - <> - - - - - - - - - - - - - - - - - - - {client.uuid && ( + > + {client && ( + <> +
{t('pages.clients.online')} - {client.enable && isOnline - ? {t('pages.clients.online')} - : {t('pages.clients.offline')}} - {t('lastOnline')}: {dateLabel(traffic?.lastOnline)} -
{t('status')} - - {client.enable ? t('enabled') : t('disabled')} - -
{t('pages.clients.email')} - {client.email - ? {client.email} - : {t('none')}} -
{t('pages.clients.subId')} - {client.subId || '-'} - {client.subId && ( -
+ - + - )} - {client.password && ( - + - )} - {client.auth && ( - + - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {client.comment && ( - - - - )} - - - + - - -
{t('pages.clients.uuid')}{t('pages.clients.online')} - {client.uuid} -
{t('password')}{t('status')} - {client.password} -
{t('pages.clients.auth')}{t('pages.clients.email')} - {client.auth} -
{t('pages.clients.flow')} - {client.flow ? {client.flow} : {t('none')}} -
{t('pages.inbounds.traffic')} - - ↑ {SizeFormatter.sizeFormat(traffic?.up || 0)} - {' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)} - - - {SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'} - -
{t('remained')} - {remaining < 0 - ? - : 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}} -
{t('pages.inbounds.expireDate')} - {!client.expiryTime - ? - : {expiryLabel(client.expiryTime)}} - {(client.expiryTime ?? 0) > 0 && ( - {IntlUtil.formatRelativeTime(client.expiryTime)} - )} -
{t('pages.clients.ipLimit')}{!client.limitIp ? : {client.limitIp}}
{t('pages.inbounds.createdAt')}{dateLabel(client.createdAt)}
{t('pages.inbounds.updatedAt')}{dateLabel(client.updatedAt)}
{t('pages.clients.comment')}{client.comment}
{t('pages.clients.attachedInbounds')} -
- {(client.inboundIds || []).map((id) => { - const ib = inboundsById[id]; - return ( - - {ib ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})` : `#${id}`} - - ); - })} - {(!client.inboundIds || client.inboundIds.length === 0) && ( - +
{t('pages.clients.subId')} + {client.subId || '-'} + {client.subId && ( +
+ + + {client.uuid && ( + + {t('pages.clients.uuid')} + + {client.uuid} +