| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596 |
- 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<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();
- }
- // 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;
- subJsonURI: string;
- subJsonEnable: boolean;
- subClashURI: string;
- subClashEnable: boolean;
- }
- interface ClientInfoModalProps {
- open: boolean;
- client: ClientRecord | null;
- inboundsById: Record<number, InboundOption>;
- isOnline: boolean;
- subSettings?: SubSettings;
- onOpenChange: (open: boolean) => void;
- }
- interface ApiMsg<T = unknown> {
- 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<string[]>([]);
- const [clientIps, setClientIps] = useState<string[]>([]);
- 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<string[]>;
- 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<unknown[]>;
- 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}
- <Modal
- open={open}
- title={client ? `${t('pages.clients.clientInfo')} — ${client.email}` : t('pages.clients.clientInfo')}
- 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 && (
- <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.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.IPLimitlog')}</td>
- <td>
- <Button size="small" icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
- {clientIps.length > 0 ? clientIps.length : ''}
- </Button>
- </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>
- </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) => {
- const ib = inboundsById[id];
- const proto = (ib?.protocol || '').toLowerCase();
- const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
- const label = ib?.tag ?? '';
- return (
- <Tooltip key={id} title={label}>
- <Tag color={color}>{label}</Tag>
- </Tooltip>
- );
- };
- return (
- <div className="chips">
- {visible.map((id) => inboundChip(id))}
- {overflow.length > 0 && (
- <Popover
- trigger="click"
- placement="bottomRight"
- content={
- <div className="chips chips-stack">
- {overflow.map((id) => inboundChip(id))}
- </div>
- }
- >
- <Tag color="default" className="chip-more">
- +{overflow.length} {t('more') !== 'more' ? t('more') : 'more'}
- </Tag>
- </Popover>
- )}
- </div>
- );
- })()}
- </td>
- </tr>
- </tbody>
- </table>
- {links.length > 0 && (
- <>
- <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 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>
- <div className="link-row-actions">
- <Tooltip title={t('copy')}>
- <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
- </Tooltip>
- {canQr && (
- <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-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(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>
- </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>
- )}
- {subClashLink && (
- <div className="link-row">
- <Tooltip title="Clash / Mihomo">
- <Tag color="gold" className="link-row-tag">CLASH</Tag>
- </Tooltip>
- <a
- href={subClashLink}
- target="_blank"
- rel="noopener noreferrer"
- className="link-row-title link-row-title-anchor"
- title={subClashLink}
- >
- {client.subId}
- </a>
- <div className="link-row-actions">
- <Tooltip title={t('copy')}>
- <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subClashLink)} />
- </Tooltip>
- <Popover
- trigger="click"
- placement="left"
- destroyOnHidden
- content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
- >
- <Tooltip title={t('pages.clients.qrCode')}>
- <Button size="small" icon={<QrcodeOutlined />} />
- </Tooltip>
- </Popover>
- </div>
- </div>
- )}
- </>
- )}
- </>
- )}
- </Modal>
- <Modal
- open={ipsModalOpen}
- title={`${t('pages.inbounds.IPLimitlog')}${client?.email ? ` — ${client.email}` : ''}`}
- width={440}
- onCancel={() => setIpsModalOpen(false)}
- footer={[
- <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
- {t('refresh')}
- </Button>,
- <Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
- {t('pages.clients.clearAll')}
- </Button>,
- <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
- {t('close')}
- </Button>,
- ]}
- >
- {clientIps.length > 0 ? (
- <div style={{ maxHeight: 360, overflowY: 'auto' }}>
- {clientIps.map((ip, idx) => (
- <Tag
- key={idx}
- color="blue"
- style={{
- display: 'block',
- width: 'fit-content',
- maxWidth: '100%',
- marginBottom: 6,
- padding: '2px 8px',
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
- }}
- >
- {ip}
- </Tag>
- ))}
- </div>
- ) : (
- <Tag>{t('tgbot.noIpRecord')}</Tag>
- )}
- </Modal>
- </>
- );
- }
|