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