| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918 |
- import { useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
- import {
- CopyOutlined,
- DownloadOutlined,
- EyeOutlined,
- QrcodeOutlined,
- ReloadOutlined,
- } from '@ant-design/icons';
- import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
- import { formatInboundLabel, formatTunnelConfigMeta } from '@/lib/inbounds/label';
- import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
- import { useDatepicker } from '@/hooks/useDatepicker';
- import { useClientHwids } from '@/hooks/useClientHwids';
- import type { ClientRecord, InboundOption } from '@/hooks/useClients';
- import { isPostQuantumLink } from '@/lib/xray/inbound-link';
- import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
- import { QrPanel } from '@/pages/inbounds/qr';
- import ClientHwidListModal from '@/components/clients/ClientHwidList';
- import ConfigBlock from '@/components/clients/ConfigBlock';
- import {
- buildWireguardClientConfig,
- findWireguardInbounds,
- isWireguardClient,
- } from './wireguardConfig';
- import {
- buildAmneziaWGClientConfig,
- findAmneziaWGInbounds,
- isAmneziaWGClient,
- } from './amneziawgConfig';
- import { tunnelConfigEndpoints, tunnelEndpointLabel } from './tunnelEndpoints';
- import type { HostRecord } from '@/schemas/api/host';
- import './ClientInfoModal.css';
- const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
- vless: 'blue',
- vmess: 'geekblue',
- trojan: 'volcano',
- shadowsocks: 'magenta',
- hysteria: 'cyan',
- hysteria2: 'green',
- wireguard: 'gold',
- amneziawg: 'yellow',
- http: 'purple',
- mixed: 'lime',
- tunnel: 'orange',
- tuic: 'orange',
- };
- const INBOUND_CHIP_LIMIT = 1;
- interface SubSettings {
- enable: boolean;
- subURI: string;
- subJsonURI: string;
- subJsonEnable: boolean;
- subClashURI: string;
- subClashEnable: boolean;
- publicHost?: string;
- }
- interface ClientInfoModalProps {
- open: boolean;
- client: ClientRecord | null;
- inboundsById: Record<number, InboundOption>;
- tunnelAllowedIPs?: Record<number, string>;
- isOnline: boolean;
- subSettings?: SubSettings;
- hosts?: HostRecord[];
- onOpenChange: (open: boolean) => void;
- }
- const NO_HOSTS: HostRecord[] = [];
- interface ApiMsg<T = unknown> {
- success?: boolean;
- obj?: T;
- }
- const DEFAULT_SUB: SubSettings = {
- enable: false,
- subURI: '',
- subJsonURI: '',
- subJsonEnable: false,
- subClashURI: '',
- subClashEnable: false,
- publicHost: '',
- };
- const SUBSCRIPTION_DOWNLOAD_NAMES = {
- standard: 'subscription-standard.txt',
- json: 'subscription-json.json',
- clash: 'subscription-clash.yaml',
- } as const;
- export default function ClientInfoModal({
- open,
- client,
- inboundsById,
- tunnelAllowedIPs,
- isOnline,
- subSettings = DEFAULT_SUB,
- hosts = NO_HOSTS,
- 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<ClientIpInfo[]>([]);
- const [ipsLoading, setIpsLoading] = useState(false);
- const [ipsClearing, setIpsClearing] = useState(false);
- const [ipsModalOpen, setIpsModalOpen] = useState(false);
- const {
- clientHwids,
- hwidsLoading,
- hwidsClearing,
- deletingHwidId,
- loadHwids,
- clearHwids,
- deleteHwid,
- resetHwids,
- } = useClientHwids(client?.email);
- const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
- const [downloadingFormat, setDownloadingFormat] = useState<
- keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null
- >(null);
- // Clearing on close happens during render; the effect owns only the fetch.
- const openSubId = open ? (client?.subId ?? '') : null;
- const [syncedSubId, setSyncedSubId] = useState(openSubId);
- if (openSubId !== syncedSubId) {
- setSyncedSubId(openSubId);
- if (openSubId === null) {
- setLinks([]);
- setClientIps([]);
- setIpsModalOpen(false);
- resetHwids();
- setHwidsModalOpen(false);
- }
- }
- useEffect(() => {
- if (!open || !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 subId = client?.subId;
- const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
- const subJsonLink =
- subId && subSettings?.subJsonEnable && subSettings?.subJsonURI
- ? subSettings.subJsonURI + subId
- : '';
- const subClashLink =
- subId && subSettings?.subClashEnable && subSettings?.subClashURI
- ? subSettings.subClashURI + subId
- : '';
- const showSubscription = !!(subSettings?.enable && client?.subId);
- const wgInbounds = useMemo(
- () => findWireguardInbounds(client, inboundsById),
- [client, inboundsById],
- );
- const wgConfigs = useMemo(() => {
- if (!client || !isWireguardClient(client)) return [];
- const host = window.location.hostname;
- const publicHost = subSettings?.publicHost ?? '';
- return wgInbounds
- .flatMap((ib) => {
- const address = tunnelAllowedIPs?.[ib.id] ?? '';
- return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
- inbound: ib,
- endpoint: tunnelEndpointLabel(ep),
- text: buildWireguardClientConfig(client, ib, host, publicHost, address, ep),
- }));
- })
- .filter((c) => !!c.text);
- }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]);
- const awgInbounds = useMemo(
- () => findAmneziaWGInbounds(client, inboundsById),
- [client, inboundsById],
- );
- const awgConfigs = useMemo(() => {
- if (!client || !isAmneziaWGClient(client)) return [];
- const host = window.location.hostname;
- const publicHost = subSettings?.publicHost ?? '';
- return awgInbounds
- .flatMap((ib) => {
- const address = tunnelAllowedIPs?.[ib.id] ?? '';
- return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
- inbound: ib,
- endpoint: tunnelEndpointLabel(ep),
- text: buildAmneziaWGClientConfig(client, ib, host, publicHost, address, ep),
- }));
- })
- .filter((c) => !!c.text);
- }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]);
- async function copyValue(text: string) {
- if (!text) return;
- const ok = await ClipboardManager.copyText(String(text));
- if (ok) messageApi.success(t('copied'));
- }
- async function downloadSubscription(
- url: string,
- format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES,
- ) {
- if (!url || downloadingFormat) return;
- setDownloadingFormat(format);
- try {
- const response = await fetch(url);
- if (!response.ok) throw new Error('Subscription download failed');
- const content = await response.text();
- FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
- } catch (_) {
- messageApi.error(t('somethingWentWrong'));
- } finally {
- setDownloadingFormat(null);
- }
- }
- 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;
- }
- setClientIps(normalizeClientIps(msg.obj));
- } 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();
- }
- function openHwidsModal() {
- setHwidsModalOpen(true);
- if (clientHwids.length === 0) void loadHwids();
- }
- 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)}
- {' · '}
- {t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
- </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 />}
- aria-label={t('copy')}
- 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 />}
- aria-label={t('copy')}
- 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 />}
- aria-label={t('copy')}
- 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 />}
- aria-label={t('copy')}
- 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 />}
- aria-label={t('pages.clients.ipLog')}
- loading={ipsLoading}
- onClick={openIpsModal}
- >
- {clientIps.length > 0 ? clientIps.length : ''}
- </Button>
- </td>
- </tr>
- {(traffic?.resetMax ?? 0) > 0 && (
- <tr>
- <td>{t('pages.clients.renewsUsed')}</td>
- <td>
- <Tag
- color={
- (traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'
- }
- >
- {traffic?.resetCount ?? 0} / {traffic?.resetMax}
- </Tag>
- </td>
- </tr>
- )}
- <tr>
- <td>{t('pages.clients.limitHwid')}</td>
- <td>{!client.limitHwid ? <Tag>∞</Tag> : <Tag>{client.limitHwid}</Tag>}</td>
- </tr>
- <tr>
- <td>{t('pages.clients.hwidLog')}</td>
- <td>
- <Button
- size="small"
- icon={<EyeOutlined />}
- aria-label={t('pages.clients.hwidLog')}
- loading={hwidsLoading}
- onClick={openHwidsModal}
- >
- {clientHwids.length > 0 ? clientHwids.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.group && (
- <tr>
- <td>{t('pages.clients.group')}</td>
- <td>
- <Tag color="geekblue">{client.group}</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 = formatInboundLabel(ib?.tag, ib?.remark);
- 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>
- {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 />}
- aria-label={t('copy')}
- onClick={() => copyValue(subLink)}
- />
- </Tooltip>
- <Tooltip title={t('download')}>
- <Button
- size="small"
- icon={<DownloadOutlined />}
- aria-label={t('download')}
- loading={downloadingFormat === 'standard'}
- disabled={downloadingFormat !== null}
- onClick={() => void downloadSubscription(subLink, 'standard')}
- />
- </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 />}
- aria-label={t('pages.clients.qrCode')}
- />
- </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 />}
- aria-label={t('copy')}
- onClick={() => copyValue(subJsonLink)}
- />
- </Tooltip>
- <Tooltip title={t('download')}>
- <Button
- size="small"
- icon={<DownloadOutlined />}
- aria-label={t('download')}
- loading={downloadingFormat === 'json'}
- disabled={downloadingFormat !== null}
- onClick={() => void downloadSubscription(subJsonLink, 'json')}
- />
- </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 />}
- aria-label={t('pages.clients.qrCode')}
- />
- </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 />}
- aria-label={t('copy')}
- onClick={() => copyValue(subClashLink)}
- />
- </Tooltip>
- <Tooltip title={t('download')}>
- <Button
- size="small"
- icon={<DownloadOutlined />}
- aria-label={t('download')}
- loading={downloadingFormat === 'clash'}
- disabled={downloadingFormat !== null}
- onClick={() => void downloadSubscription(subClashLink, 'clash')}
- />
- </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 />}
- aria-label={t('pages.clients.qrCode')}
- />
- </Tooltip>
- </Popover>
- </div>
- </div>
- )}
- </>
- )}
- {links.length > 0 && (
- <>
- <Divider>{t('pages.inbounds.copyLink')}</Divider>
- {links.map((link, idx) => {
- const parts = parseLinkParts(link);
- const fallback = `${t('pages.clients.link')} ${idx + 1}`;
- const rowTitle = (parts && linkMetaText(parts)) || fallback;
- const qrRemark = parts?.remark || rowTitle;
- const canQr = !isPostQuantumLink(link);
- return (
- <div key={idx} className="link-row">
- {parts ? (
- <LinkTags parts={parts} />
- ) : (
- <Tag className="link-row-tag">LINK</Tag>
- )}
- <span className="link-row-title" title={rowTitle}>
- {rowTitle}
- </span>
- <div className="link-row-actions">
- <Tooltip title={t('copy')}>
- <Button
- size="small"
- icon={<CopyOutlined />}
- aria-label={t('copy')}
- 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 />}
- aria-label={t('pages.clients.qrCode')}
- />
- </Tooltip>
- </Popover>
- )}
- </div>
- </div>
- );
- })}
- </>
- )}
- {wgConfigs.length > 0 && client && (
- <>
- <Divider>{t('pages.clients.wireguardConfig')}</Divider>
- {wgConfigs.map(({ inbound, endpoint, text }) => {
- const meta = formatTunnelConfigMeta(
- inbound,
- client.email,
- wgConfigs.length,
- endpoint,
- );
- return (
- <ConfigBlock
- key={`wg-${inbound.id}-${endpoint}`}
- label={meta.label || t('pages.clients.config')}
- text={text}
- fileName={meta.fileName}
- qrRemark={meta.qrRemark}
- tagColor="cyan"
- />
- );
- })}
- </>
- )}
- {awgConfigs.length > 0 && client && (
- <>
- <Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
- {awgConfigs.map(({ inbound, endpoint, text }) => {
- const meta = formatTunnelConfigMeta(
- inbound,
- client.email,
- awgConfigs.length,
- endpoint,
- );
- return (
- <ConfigBlock
- key={`awg-${inbound.id}-${endpoint}`}
- label={meta.label || t('pages.clients.config')}
- text={text}
- fileName={meta.fileName}
- qrRemark={meta.qrRemark}
- tagColor="purple"
- />
- );
- })}
- </>
- )}
- </>
- )}
- </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((entry, 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',
- }}
- >
- {entry.ip}
- {entry.time ? ` (${entry.time})` : ''}
- {entry.node ? (
- <span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>
- @ {entry.node}
- </span>
- ) : null}
- </Tag>
- ))}
- </div>
- ) : (
- <Tag>{t('tgbot.noIpRecord')}</Tag>
- )}
- </Modal>
- <ClientHwidListModal
- open={hwidsModalOpen}
- email={client?.email}
- hwids={clientHwids}
- loading={hwidsLoading}
- clearing={hwidsClearing}
- deletingId={deletingHwidId}
- formatDate={dateLabel}
- onRefresh={loadHwids}
- onClearAll={clearHwids}
- onDelete={deleteHwid}
- onClose={() => setHwidsModalOpen(false)}
- />
- </>
- );
- }
|