ClientInfoModal.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
  4. import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
  5. import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
  6. import { formatInboundLabel } from '@/lib/inbounds/label';
  7. import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
  8. import { useDatepicker } from '@/hooks/useDatepicker';
  9. import type { ClientRecord, InboundOption } from '@/hooks/useClients';
  10. import { isPostQuantumLink } from '@/lib/xray/inbound-link';
  11. import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
  12. import { QrPanel } from '@/pages/inbounds/qr';
  13. import './ClientInfoModal.css';
  14. const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
  15. vless: 'blue',
  16. vmess: 'geekblue',
  17. trojan: 'volcano',
  18. shadowsocks: 'magenta',
  19. hysteria: 'cyan',
  20. hysteria2: 'green',
  21. wireguard: 'gold',
  22. http: 'purple',
  23. mixed: 'lime',
  24. tunnel: 'orange',
  25. };
  26. const INBOUND_CHIP_LIMIT = 1;
  27. interface SubSettings {
  28. enable: boolean;
  29. subURI: string;
  30. subJsonURI: string;
  31. subJsonEnable: boolean;
  32. subClashURI: string;
  33. subClashEnable: boolean;
  34. }
  35. interface ClientInfoModalProps {
  36. open: boolean;
  37. client: ClientRecord | null;
  38. inboundsById: Record<number, InboundOption>;
  39. isOnline: boolean;
  40. subSettings?: SubSettings;
  41. onOpenChange: (open: boolean) => void;
  42. }
  43. interface ApiMsg<T = unknown> {
  44. success?: boolean;
  45. obj?: T;
  46. }
  47. const DEFAULT_SUB: SubSettings = {
  48. enable: false,
  49. subURI: '',
  50. subJsonURI: '',
  51. subJsonEnable: false,
  52. subClashURI: '',
  53. subClashEnable: false,
  54. };
  55. export default function ClientInfoModal({
  56. open,
  57. client,
  58. inboundsById,
  59. isOnline,
  60. subSettings = DEFAULT_SUB,
  61. onOpenChange,
  62. }: ClientInfoModalProps) {
  63. const { datepicker } = useDatepicker();
  64. const { t } = useTranslation();
  65. const expiryLabel = (ts?: number) => {
  66. if (!ts) return '∞';
  67. if (ts < 0) {
  68. const days = Math.round(ts / -86400000);
  69. return `${t('pages.clients.delayedStart')}: ${days}d`;
  70. }
  71. return IntlUtil.formatDate(ts, datepicker);
  72. };
  73. const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
  74. const [messageApi, messageContextHolder] = message.useMessage();
  75. const [links, setLinks] = useState<string[]>([]);
  76. const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
  77. const [ipsLoading, setIpsLoading] = useState(false);
  78. const [ipsClearing, setIpsClearing] = useState(false);
  79. const [ipsModalOpen, setIpsModalOpen] = useState(false);
  80. useEffect(() => {
  81. if (!open) {
  82. setLinks([]);
  83. setClientIps([]);
  84. setIpsModalOpen(false);
  85. return;
  86. }
  87. if (!client?.subId) return;
  88. let cancelled = false;
  89. (async () => {
  90. const msg = await HttpUtil.get(
  91. `/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`,
  92. ) as ApiMsg<string[]>;
  93. if (cancelled) return;
  94. setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []);
  95. })();
  96. return () => { cancelled = true; };
  97. }, [open, client?.subId]);
  98. const traffic = client?.traffic || null;
  99. const totalBytes = client?.totalGB || 0;
  100. const used = (traffic?.up || 0) + (traffic?.down || 0);
  101. const remaining = useMemo(() => {
  102. if (totalBytes <= 0) return -1;
  103. const r = totalBytes - used;
  104. return r > 0 ? r : 0;
  105. }, [totalBytes, used]);
  106. const subLink = useMemo(() => {
  107. if (!client?.subId || !subSettings?.subURI) return '';
  108. return subSettings.subURI + client.subId;
  109. }, [client?.subId, subSettings?.subURI]);
  110. const subJsonLink = useMemo(() => {
  111. if (!client?.subId) return '';
  112. if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
  113. return subSettings.subJsonURI + client.subId;
  114. }, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
  115. const subClashLink = useMemo(() => {
  116. if (!client?.subId) return '';
  117. if (!subSettings?.subClashEnable || !subSettings?.subClashURI) return '';
  118. return subSettings.subClashURI + client.subId;
  119. }, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
  120. const showSubscription = !!(subSettings?.enable && client?.subId);
  121. async function copyValue(text: string) {
  122. if (!text) return;
  123. const ok = await ClipboardManager.copyText(String(text));
  124. if (ok) messageApi.success(t('copied'));
  125. }
  126. async function loadIps() {
  127. if (!client?.email) return;
  128. setIpsLoading(true);
  129. try {
  130. const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
  131. if (!msg?.success) { setClientIps([]); return; }
  132. setClientIps(normalizeClientIps(msg.obj));
  133. } finally {
  134. setIpsLoading(false);
  135. }
  136. }
  137. async function clearIps() {
  138. if (!client?.email) return;
  139. setIpsClearing(true);
  140. try {
  141. const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg;
  142. if (msg?.success) setClientIps([]);
  143. } finally {
  144. setIpsClearing(false);
  145. }
  146. }
  147. function openIpsModal() {
  148. setIpsModalOpen(true);
  149. if (clientIps.length === 0) void loadIps();
  150. }
  151. return (
  152. <>
  153. {messageContextHolder}
  154. <Modal
  155. open={open}
  156. title={client ? `${t('pages.clients.clientInfo')} — ${client.email}` : t('pages.clients.clientInfo')}
  157. footer={null}
  158. width={640}
  159. onCancel={() => onOpenChange(false)}
  160. >
  161. {client && (
  162. <>
  163. <table className="info-table block">
  164. <tbody>
  165. <tr>
  166. <td>{t('pages.clients.online')}</td>
  167. <td>
  168. {client.enable && isOnline
  169. ? <Tag color="green">{t('pages.clients.online')}</Tag>
  170. : <Tag>{t('pages.clients.offline')}</Tag>}
  171. <span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
  172. </td>
  173. </tr>
  174. <tr>
  175. <td>{t('status')}</td>
  176. <td>
  177. <Tag color={client.enable ? 'green' : 'default'}>
  178. {client.enable ? t('enabled') : t('disabled')}
  179. </Tag>
  180. </td>
  181. </tr>
  182. <tr>
  183. <td>{t('pages.clients.email')}</td>
  184. <td>
  185. {client.email
  186. ? <Tag color="green">{client.email}</Tag>
  187. : <Tag color="red">{t('none')}</Tag>}
  188. </td>
  189. </tr>
  190. <tr>
  191. <td>{t('pages.clients.subId')}</td>
  192. <td>
  193. <Tag className="info-large-tag">{client.subId || '-'}</Tag>
  194. {client.subId && (
  195. <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
  196. )}
  197. </td>
  198. </tr>
  199. {client.uuid && (
  200. <tr>
  201. <td>{t('pages.clients.uuid')}</td>
  202. <td>
  203. <Tag className="info-large-tag">{client.uuid}</Tag>
  204. <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
  205. </td>
  206. </tr>
  207. )}
  208. {client.password && (
  209. <tr>
  210. <td>{t('password')}</td>
  211. <td>
  212. <Tag className="info-large-tag">{client.password}</Tag>
  213. <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
  214. </td>
  215. </tr>
  216. )}
  217. {client.auth && (
  218. <tr>
  219. <td>{t('pages.clients.auth')}</td>
  220. <td>
  221. <Tag className="info-large-tag">{client.auth}</Tag>
  222. <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
  223. </td>
  224. </tr>
  225. )}
  226. <tr>
  227. <td>{t('pages.clients.flow')}</td>
  228. <td>
  229. {client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
  230. </td>
  231. </tr>
  232. <tr>
  233. <td>{t('pages.inbounds.traffic')}</td>
  234. <td>
  235. <Tag>
  236. ↑ {SizeFormatter.sizeFormat(traffic?.up || 0)}
  237. {' '}/ ↓ {SizeFormatter.sizeFormat(traffic?.down || 0)}
  238. </Tag>
  239. <span className="hint">
  240. {SizeFormatter.sizeFormat(used)} / {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
  241. </span>
  242. </td>
  243. </tr>
  244. <tr>
  245. <td>{t('remained')}</td>
  246. <td>
  247. {remaining < 0
  248. ? <Tag color="purple">∞</Tag>
  249. : <Tag color={remaining > 0 ? '' : 'red'}>{SizeFormatter.sizeFormat(remaining)}</Tag>}
  250. </td>
  251. </tr>
  252. <tr>
  253. <td>{t('pages.inbounds.expireDate')}</td>
  254. <td>
  255. {!client.expiryTime
  256. ? <Tag color="purple">∞</Tag>
  257. : <Tag color={client.expiryTime < 0 ? 'blue' : undefined}>{expiryLabel(client.expiryTime)}</Tag>}
  258. {(client.expiryTime ?? 0) > 0 && (
  259. <span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
  260. )}
  261. </td>
  262. </tr>
  263. <tr>
  264. <td>{t('pages.clients.ipLimit')}</td>
  265. <td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
  266. </tr>
  267. <tr>
  268. <td>{t('pages.inbounds.IPLimitlog')}</td>
  269. <td>
  270. <Button size="small" icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
  271. {clientIps.length > 0 ? clientIps.length : ''}
  272. </Button>
  273. </td>
  274. </tr>
  275. <tr>
  276. <td>{t('pages.inbounds.createdAt')}</td>
  277. <td><Tag>{dateLabel(client.createdAt)}</Tag></td>
  278. </tr>
  279. <tr>
  280. <td>{t('pages.inbounds.updatedAt')}</td>
  281. <td><Tag>{dateLabel(client.updatedAt)}</Tag></td>
  282. </tr>
  283. {client.comment && (
  284. <tr>
  285. <td>{t('pages.clients.comment')}</td>
  286. <td><Tag className="info-large-tag">{client.comment}</Tag></td>
  287. </tr>
  288. )}
  289. <tr>
  290. <td>{t('pages.clients.attachedInbounds')}</td>
  291. <td>
  292. {(() => {
  293. const ids = client.inboundIds || [];
  294. if (ids.length === 0) return <span className="hint">—</span>;
  295. const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
  296. const overflow = ids.slice(INBOUND_CHIP_LIMIT);
  297. const inboundChip = (id: number) => {
  298. const ib = inboundsById[id];
  299. const proto = (ib?.protocol || '').toLowerCase();
  300. const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
  301. const label = formatInboundLabel(ib?.tag, ib?.remark);
  302. return (
  303. <Tooltip key={id} title={label}>
  304. <Tag color={color}>{label}</Tag>
  305. </Tooltip>
  306. );
  307. };
  308. return (
  309. <div className="chips">
  310. {visible.map((id) => inboundChip(id))}
  311. {overflow.length > 0 && (
  312. <Popover
  313. trigger="click"
  314. placement="bottomRight"
  315. content={
  316. <div className="chips chips-stack">
  317. {overflow.map((id) => inboundChip(id))}
  318. </div>
  319. }
  320. >
  321. <Tag color="default" className="chip-more">
  322. +{overflow.length} {t('more') !== 'more' ? t('more') : 'more'}
  323. </Tag>
  324. </Popover>
  325. )}
  326. </div>
  327. );
  328. })()}
  329. </td>
  330. </tr>
  331. </tbody>
  332. </table>
  333. {links.length > 0 && (
  334. <>
  335. <Divider>{t('pages.inbounds.copyLink')}</Divider>
  336. {links.map((link, idx) => {
  337. const parts = parseLinkParts(link);
  338. const fallback = `${t('pages.clients.link')} ${idx + 1}`;
  339. const rowTitle = (parts && linkMetaText(parts)) || fallback;
  340. const qrRemark = parts?.remark || rowTitle;
  341. const canQr = !isPostQuantumLink(link);
  342. return (
  343. <div key={idx} className="link-row">
  344. {parts
  345. ? <LinkTags parts={parts} />
  346. : <Tag className="link-row-tag">LINK</Tag>}
  347. <span className="link-row-title" title={rowTitle}>{rowTitle}</span>
  348. <div className="link-row-actions">
  349. <Tooltip title={t('copy')}>
  350. <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
  351. </Tooltip>
  352. {canQr && (
  353. <Popover
  354. trigger="click"
  355. placement="left"
  356. destroyOnHidden
  357. content={<QrPanel value={link} remark={qrRemark} size={220} />}
  358. >
  359. <Tooltip title={t('pages.clients.qrCode')}>
  360. <Button size="small" icon={<QrcodeOutlined />} />
  361. </Tooltip>
  362. </Popover>
  363. )}
  364. </div>
  365. </div>
  366. );
  367. })}
  368. </>
  369. )}
  370. {showSubscription && subLink && (
  371. <>
  372. <Divider>{t('subscription.title')}</Divider>
  373. <div className="link-row">
  374. <Tag color="green" className="link-row-tag">SUB</Tag>
  375. <a
  376. href={subLink}
  377. target="_blank"
  378. rel="noopener noreferrer"
  379. className="link-row-title link-row-title-anchor"
  380. title={subLink}
  381. >
  382. {client.subId}
  383. </a>
  384. <div className="link-row-actions">
  385. <Tooltip title={t('copy')}>
  386. <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
  387. </Tooltip>
  388. <Popover
  389. trigger="click"
  390. placement="left"
  391. destroyOnHidden
  392. content={<QrPanel value={subLink} remark={`${client.email} — ${t('subscription.title')}`} size={220} />}
  393. >
  394. <Tooltip title={t('pages.clients.qrCode')}>
  395. <Button size="small" icon={<QrcodeOutlined />} />
  396. </Tooltip>
  397. </Popover>
  398. </div>
  399. </div>
  400. {subJsonLink && (
  401. <div className="link-row">
  402. <Tag color="purple" className="link-row-tag">JSON</Tag>
  403. <a
  404. href={subJsonLink}
  405. target="_blank"
  406. rel="noopener noreferrer"
  407. className="link-row-title link-row-title-anchor"
  408. title={subJsonLink}
  409. >
  410. {client.subId}
  411. </a>
  412. <div className="link-row-actions">
  413. <Tooltip title={t('copy')}>
  414. <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
  415. </Tooltip>
  416. <Popover
  417. trigger="click"
  418. placement="left"
  419. destroyOnHidden
  420. content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
  421. >
  422. <Tooltip title={t('pages.clients.qrCode')}>
  423. <Button size="small" icon={<QrcodeOutlined />} />
  424. </Tooltip>
  425. </Popover>
  426. </div>
  427. </div>
  428. )}
  429. {subClashLink && (
  430. <div className="link-row">
  431. <Tooltip title="Clash / Mihomo">
  432. <Tag color="gold" className="link-row-tag">CLASH</Tag>
  433. </Tooltip>
  434. <a
  435. href={subClashLink}
  436. target="_blank"
  437. rel="noopener noreferrer"
  438. className="link-row-title link-row-title-anchor"
  439. title={subClashLink}
  440. >
  441. {client.subId}
  442. </a>
  443. <div className="link-row-actions">
  444. <Tooltip title={t('copy')}>
  445. <Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subClashLink)} />
  446. </Tooltip>
  447. <Popover
  448. trigger="click"
  449. placement="left"
  450. destroyOnHidden
  451. content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
  452. >
  453. <Tooltip title={t('pages.clients.qrCode')}>
  454. <Button size="small" icon={<QrcodeOutlined />} />
  455. </Tooltip>
  456. </Popover>
  457. </div>
  458. </div>
  459. )}
  460. </>
  461. )}
  462. </>
  463. )}
  464. </Modal>
  465. <Modal
  466. open={ipsModalOpen}
  467. title={`${t('pages.inbounds.IPLimitlog')}${client?.email ? ` — ${client.email}` : ''}`}
  468. width={440}
  469. onCancel={() => setIpsModalOpen(false)}
  470. footer={[
  471. <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
  472. {t('refresh')}
  473. </Button>,
  474. <Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
  475. {t('pages.clients.clearAll')}
  476. </Button>,
  477. <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
  478. {t('close')}
  479. </Button>,
  480. ]}
  481. >
  482. {clientIps.length > 0 ? (
  483. <div style={{ maxHeight: 360, overflowY: 'auto' }}>
  484. {clientIps.map((entry, idx) => (
  485. <Tag
  486. key={idx}
  487. color="blue"
  488. style={{
  489. display: 'block',
  490. width: 'fit-content',
  491. maxWidth: '100%',
  492. marginBottom: 6,
  493. padding: '2px 8px',
  494. fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
  495. }}
  496. >
  497. {entry.ip}{entry.time ? ` (${entry.time})` : ''}
  498. {entry.node ? (
  499. <span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
  500. ) : null}
  501. </Tag>
  502. ))}
  503. </div>
  504. ) : (
  505. <Tag>{t('tgbot.noIpRecord')}</Tag>
  506. )}
  507. </Modal>
  508. </>
  509. );
  510. }