import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd'; import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons'; 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 { QrPanel } from '@/pages/inbounds/qr'; 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(); } // 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 = { 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; subJsonURI: string; subJsonEnable: boolean; subClashURI: string; subClashEnable: boolean; } interface ClientInfoModalProps { open: boolean; client: ClientRecord | null; inboundsById: Record; isOnline: boolean; subSettings?: SubSettings; onOpenChange: (open: boolean) => void; } interface ApiMsg { success?: boolean; obj?: T; } const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, subClashURI: '', subClashEnable: false, }; export default function ClientInfoModal({ open, client, inboundsById, isOnline, subSettings = DEFAULT_SUB, onOpenChange, }: ClientInfoModalProps) { const { datepicker } = useDatepicker(); const { t } = useTranslation(); const expiryLabel = (ts?: number) => { if (!ts) return '∞'; if (ts < 0) { const days = Math.round(ts / -86400000); return `${t('pages.clients.delayedStart')}: ${days}d`; } return IntlUtil.formatDate(ts, datepicker); }; const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker)); const [messageApi, messageContextHolder] = message.useMessage(); const [links, setLinks] = useState([]); const [clientIps, setClientIps] = useState([]); const [ipsLoading, setIpsLoading] = useState(false); const [ipsClearing, setIpsClearing] = useState(false); const [ipsModalOpen, setIpsModalOpen] = useState(false); useEffect(() => { if (!open) { setLinks([]); setClientIps([]); setIpsModalOpen(false); return; } if (!client?.subId) return; let cancelled = false; (async () => { const msg = await HttpUtil.get( `/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`, ) as ApiMsg; if (cancelled) return; setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []); })(); return () => { cancelled = true; }; }, [open, client?.subId]); const traffic = client?.traffic || null; const totalBytes = client?.totalGB || 0; const used = (traffic?.up || 0) + (traffic?.down || 0); const remaining = useMemo(() => { if (totalBytes <= 0) return -1; const r = totalBytes - used; return r > 0 ? r : 0; }, [totalBytes, used]); const subLink = useMemo(() => { if (!client?.subId || !subSettings?.subURI) return ''; return subSettings.subURI + client.subId; }, [client?.subId, subSettings?.subURI]); const subJsonLink = useMemo(() => { if (!client?.subId) return ''; if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return ''; return subSettings.subJsonURI + client.subId; }, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]); const subClashLink = useMemo(() => { if (!client?.subId) return ''; if (!subSettings?.subClashEnable || !subSettings?.subClashURI) return ''; return subSettings.subClashURI + client.subId; }, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]); const showSubscription = !!(subSettings?.enable && client?.subId); async function copyValue(text: string) { if (!text) return; const ok = await ClipboardManager.copyText(String(text)); if (ok) messageApi.success(t('copied')); } async function loadIps() { if (!client?.email) return; setIpsLoading(true); try { const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg; if (!msg?.success) { setClientIps([]); return; } const arr = Array.isArray(msg.obj) ? msg.obj : []; setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0)); } finally { setIpsLoading(false); } } async function clearIps() { if (!client?.email) return; setIpsClearing(true); try { const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg; if (msg?.success) setClientIps([]); } finally { setIpsClearing(false); } } function openIpsModal() { setIpsModalOpen(true); if (clientIps.length === 0) void loadIps(); } return ( <> {messageContextHolder} onOpenChange(false)} > {client && ( <> {client.uuid && ( )} {client.password && ( )} {client.auth && ( )} {client.comment && ( )}
{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 && (
{t('pages.clients.uuid')} {client.uuid}
{t('password')} {client.password}
{t('pages.clients.auth')} {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.IPLimitlog')}
{t('pages.inbounds.createdAt')} {dateLabel(client.createdAt)}
{t('pages.inbounds.updatedAt')} {dateLabel(client.updatedAt)}
{t('pages.clients.comment')} {client.comment}
{t('pages.clients.attachedInbounds')} {(() => { const ids = client.inboundIds || []; if (ids.length === 0) return ; const visible = ids.slice(0, INBOUND_CHIP_LIMIT); const overflow = ids.slice(INBOUND_CHIP_LIMIT); const inboundChip = (id: number) => { const ib = inboundsById[id]; const proto = (ib?.protocol || '').toLowerCase(); const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default'; const label = ib?.tag ?? ''; return ( {label} ); }; return (
{visible.map((id) => inboundChip(id))} {overflow.length > 0 && ( {overflow.map((id) => inboundChip(id))}
} > +{overflow.length} {t('more') !== 'more' ? t('more') : 'more'} )} ); })()}
{links.length > 0 && ( <> {t('pages.inbounds.copyLink')} {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 canQr = !isPostQuantumLink(link); return (
{meta.protocol} {rowTitle}
); })} )} {showSubscription && subLink && ( <> {t('subscription.title')}
SUB {client.subId}
{subJsonLink && (
JSON {client.subId}
)} {subClashLink && (
CLASH {client.subId}
)} )} )}
setIpsModalOpen(false)} footer={[ , , , ]} > {clientIps.length > 0 ? (
{clientIps.map((ip, idx) => ( {ip} ))}
) : ( {t('tgbot.noIpRecord')} )}
); }