InboundsPage.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Card,
  5. Col,
  6. ConfigProvider,
  7. Layout,
  8. Modal,
  9. Row,
  10. Spin,
  11. Statistic,
  12. message,
  13. } from 'antd';
  14. import { setMessageInstance } from '@/utils/messageBus';
  15. import {
  16. SwapOutlined,
  17. PieChartOutlined,
  18. BarsOutlined,
  19. } from '@ant-design/icons';
  20. import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
  21. import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
  22. import { genInboundLinks } from '@/lib/xray/inbound-link';
  23. import { inboundFromDb } from '@/lib/xray/inbound-from-db';
  24. import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
  25. import { useTheme } from '@/hooks/useTheme';
  26. import { useMediaQuery } from '@/hooks/useMediaQuery';
  27. import { useWebSocket } from '@/hooks/useWebSocket';
  28. import { useNodesQuery } from '@/api/queries/useNodesQuery';
  29. import AppSidebar from '@/components/AppSidebar';
  30. const TextModal = lazy(() => import('@/components/TextModal'));
  31. const PromptModal = lazy(() => import('@/components/PromptModal'));
  32. import { useInbounds } from './useInbounds';
  33. import InboundList from './InboundList';
  34. import LazyMount from '@/components/LazyMount';
  35. const InboundFormModal = lazy(() => import('./InboundFormModal'));
  36. const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
  37. const QrCodeModal = lazy(() => import('./QrCodeModal'));
  38. const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
  39. const DetachClientsModal = lazy(() => import('./DetachClientsModal'));
  40. const AddClientsToGroupModal = lazy(() => import('./AddClientsToGroupModal'));
  41. type RowAction =
  42. | 'edit'
  43. | 'showInfo'
  44. | 'qrcode'
  45. | 'export'
  46. | 'subs'
  47. | 'clipboard'
  48. | 'delete'
  49. | 'resetTraffic'
  50. | 'delAllClients'
  51. | 'attachClients'
  52. | 'detachClients'
  53. | 'addToGroup'
  54. | 'clone';
  55. type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
  56. interface ClientMatchTarget {
  57. id?: string;
  58. email?: string;
  59. password?: string;
  60. }
  61. export default function InboundsPage() {
  62. const { t } = useTranslation();
  63. const { isDark, isUltra, antdThemeConfig } = useTheme();
  64. const { isMobile } = useMediaQuery();
  65. const {
  66. fetched,
  67. dbInbounds,
  68. clientCount,
  69. onlineClients,
  70. lastOnlineMap,
  71. totals,
  72. expireDiff,
  73. trafficDiff,
  74. pageSize,
  75. subSettings,
  76. tgBotEnable,
  77. ipLimitEnable,
  78. remarkModel,
  79. refresh,
  80. hydrateInbound,
  81. applyTrafficEvent,
  82. applyClientStatsEvent,
  83. } = useInbounds();
  84. const [modal, modalContextHolder] = Modal.useModal();
  85. const [messageApi, messageContextHolder] = message.useMessage();
  86. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  87. const { nodes: nodesList } = useNodesQuery();
  88. const nodesById = useMemo(() => {
  89. const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
  90. for (const n of nodesList || []) map.set(n.id, n);
  91. return map;
  92. }, [nodesList]);
  93. const hasActiveNode = useMemo(
  94. () => (nodesList || []).some((n) => n.enable && n.status === 'online'),
  95. [nodesList],
  96. );
  97. const hasNodeAttachedInbound = useMemo(
  98. () => (dbInbounds || []).some((ib) => ib?.nodeId != null),
  99. [dbInbounds],
  100. );
  101. const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
  102. useWebSocket({
  103. traffic: applyTrafficEvent,
  104. client_stats: applyClientStatsEvent,
  105. });
  106. const [formOpen, setFormOpen] = useState(false);
  107. const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
  108. const [formDbInbound, setFormDbInbound] = useState<DBInbound | null>(null);
  109. const [infoOpen, setInfoOpen] = useState(false);
  110. const [infoDbInbound, setInfoDbInbound] = useState<DBInbound | null>(null);
  111. const [infoClientIndex, setInfoClientIndex] = useState(0);
  112. const [qrOpen, setQrOpen] = useState(false);
  113. const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
  114. const [attachOpen, setAttachOpen] = useState(false);
  115. const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
  116. const [detachOpen, setDetachOpen] = useState(false);
  117. const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
  118. const [groupOpen, setGroupOpen] = useState(false);
  119. const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
  120. const [textOpen, setTextOpen] = useState(false);
  121. const [textTitle, setTextTitle] = useState('');
  122. const [textContent, setTextContent] = useState('');
  123. const [textFileName, setTextFileName] = useState('');
  124. const [promptOpen, setPromptOpen] = useState(false);
  125. const [promptTitle, setPromptTitle] = useState('');
  126. const [promptOkText, setPromptOkText] = useState('OK');
  127. const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
  128. const [promptInitial, setPromptInitial] = useState('');
  129. const [promptLoading, setPromptLoading] = useState(false);
  130. const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
  131. const hostOverrideFor = useCallback((dbInbound: DBInbound | null) => {
  132. if (!dbInbound || dbInbound.nodeId == null) return '';
  133. return nodesById.get(dbInbound.nodeId)?.address || '';
  134. }, [nodesById]);
  135. const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
  136. const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
  137. const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
  138. setTextTitle(opts.title);
  139. setTextContent(opts.content);
  140. setTextFileName(opts.fileName || '');
  141. setTextOpen(true);
  142. }, []);
  143. const openPrompt = useCallback((opts: {
  144. title: string;
  145. okText?: string;
  146. type?: 'textarea' | 'input';
  147. value?: string;
  148. confirm: (value: string) => Promise<boolean | void> | boolean | void;
  149. }) => {
  150. setPromptTitle(opts.title);
  151. setPromptOkText(opts.okText || 'OK');
  152. setPromptType(opts.type || 'textarea');
  153. setPromptInitial(opts.value || '');
  154. setPromptHandler(() => opts.confirm);
  155. setPromptOpen(true);
  156. }, []);
  157. const onPromptConfirm = useCallback(async (value: string) => {
  158. if (!promptHandler) {
  159. setPromptOpen(false);
  160. return;
  161. }
  162. setPromptLoading(true);
  163. try {
  164. const ok = await promptHandler(value);
  165. if (ok !== false) setPromptOpen(false);
  166. } finally {
  167. setPromptLoading(false);
  168. }
  169. }, [promptHandler]);
  170. const projectChildThroughMaster = useCallback((child: DBInbound, master: DBInbound): DBInbound => {
  171. const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
  172. projected.listen = master.listen;
  173. projected.port = master.port;
  174. const masterStream = coerceInboundJsonField(master.streamSettings) as Record<string, unknown>;
  175. const childStream = { ...(coerceInboundJsonField(child.streamSettings) as Record<string, unknown>) };
  176. childStream.security = masterStream.security;
  177. childStream.tlsSettings = masterStream.tlsSettings;
  178. childStream.realitySettings = masterStream.realitySettings;
  179. childStream.externalProxy = masterStream.externalProxy;
  180. projected.streamSettings = JSON.stringify(childStream);
  181. const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
  182. return new Ctor(projected);
  183. }, []);
  184. const checkFallback = useCallback((dbInbound: DBInbound): DBInbound => {
  185. const parent = dbInbound?.fallbackParent;
  186. if (parent?.masterId) {
  187. const master = dbInbounds.find((ib) => ib.id === parent.masterId);
  188. if (master) return projectChildThroughMaster(dbInbound, master);
  189. }
  190. if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
  191. for (const candidate of dbInbounds) {
  192. if (candidate.id === dbInbound.id) continue;
  193. if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
  194. const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
  195. if (candStream.network !== 'tcp') continue;
  196. const candSettings = coerceInboundJsonField(candidate.settings) as { fallbacks?: { dest?: string }[] };
  197. const fallbacks = candSettings.fallbacks || [];
  198. if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
  199. return projectChildThroughMaster(dbInbound, candidate);
  200. }
  201. return dbInbound;
  202. }, [dbInbounds, projectChildThroughMaster]);
  203. const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
  204. if (!client) return 0;
  205. const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: ClientMatchTarget[] };
  206. const clients = settings.clients || [];
  207. const idx = clients.findIndex((c) => {
  208. if (!c) return false;
  209. switch (dbInbound.protocol) {
  210. case 'trojan':
  211. case 'shadowsocks':
  212. return c.password === client.password && c.email === client.email;
  213. default:
  214. return c.id === client.id && c.email === client.email;
  215. }
  216. });
  217. return idx >= 0 ? idx : 0;
  218. }, []);
  219. const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
  220. const projected = checkFallback(dbInbound);
  221. openText({
  222. title: t('pages.inbounds.exportLinksTitle'),
  223. content: genInboundLinks({
  224. inbound: inboundFromDb(projected),
  225. remark: projected.remark,
  226. remarkModel,
  227. hostOverride: hostOverrideFor(dbInbound),
  228. fallbackHostname: window.location.hostname,
  229. }),
  230. fileName: projected.remark || 'inbound',
  231. });
  232. }, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
  233. const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
  234. openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
  235. }, [openText, t]);
  236. const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
  237. const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: { subId?: string }[] };
  238. const clients = settings.clients || [];
  239. const subLinks: string[] = [];
  240. for (const c of clients) {
  241. if (c.subId && subSettings.subURI) {
  242. subLinks.push(subSettings.subURI + c.subId);
  243. }
  244. }
  245. openText({
  246. title: t('pages.inbounds.exportSubsTitle'),
  247. content: [...new Set(subLinks)].join('\n'),
  248. fileName: `${dbInbound.remark || 'inbound'}-Subs`,
  249. });
  250. }, [subSettings, openText, t]);
  251. const exportAllLinks = useCallback(async () => {
  252. const hydrated = await Promise.all(
  253. dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
  254. );
  255. const out: string[] = [];
  256. for (const ib of hydrated) {
  257. const projected = checkFallback(ib);
  258. out.push(genInboundLinks({
  259. inbound: inboundFromDb(projected),
  260. remark: projected.remark,
  261. remarkModel,
  262. hostOverride: hostOverrideFor(ib),
  263. fallbackHostname: window.location.hostname,
  264. }));
  265. }
  266. openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: 'All-Inbounds' });
  267. }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText, t]);
  268. const exportAllSubs = useCallback(async () => {
  269. const hydrated = await Promise.all(
  270. dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
  271. );
  272. const out: string[] = [];
  273. for (const ib of hydrated) {
  274. const settings = coerceInboundJsonField(ib.settings) as { clients?: { subId?: string }[] };
  275. const clients = settings.clients || [];
  276. for (const c of clients) {
  277. if (c.subId && subSettings.subURI) {
  278. out.push(subSettings.subURI + c.subId);
  279. }
  280. }
  281. }
  282. openText({ title: t('pages.inbounds.exportAllSubsTitle'), content: [...new Set(out)].join('\r\n'), fileName: 'All-Inbounds-Subs' });
  283. }, [dbInbounds, hydrateInbound, subSettings, openText, t]);
  284. const importInbound = useCallback(() => {
  285. openPrompt({
  286. title: 'Import inbound',
  287. okText: 'Import',
  288. type: 'textarea',
  289. value: '',
  290. confirm: async (value) => {
  291. const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
  292. if (msg?.success) {
  293. await refresh();
  294. return true;
  295. }
  296. return false;
  297. },
  298. });
  299. }, [openPrompt, refresh]);
  300. const onAddInbound = useCallback(() => {
  301. setFormMode('add');
  302. setFormDbInbound(null);
  303. setFormOpen(true);
  304. }, []);
  305. const openEdit = useCallback((dbInbound: DBInbound) => {
  306. setFormMode('edit');
  307. setFormDbInbound(dbInbound);
  308. setFormOpen(true);
  309. }, []);
  310. const confirmDelete = useCallback((dbInbound: DBInbound) => {
  311. modal.confirm({
  312. title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
  313. content: t('pages.inbounds.deleteConfirmContent'),
  314. okText: t('delete'),
  315. okType: 'danger',
  316. cancelText: t('cancel'),
  317. onOk: async () => {
  318. const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
  319. if (msg?.success) await refresh();
  320. },
  321. });
  322. }, [modal, refresh, t]);
  323. const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
  324. modal.confirm({
  325. title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
  326. content: t('pages.inbounds.resetConfirmContent'),
  327. okText: t('reset'),
  328. cancelText: t('cancel'),
  329. onOk: async () => {
  330. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
  331. if (msg?.success) await refresh();
  332. },
  333. });
  334. }, [modal, refresh, t]);
  335. const confirmDelAllClients = useCallback((dbInbound: DBInbound) => {
  336. const count = clientCount[dbInbound.id]?.clients || 0;
  337. modal.confirm({
  338. title: t('pages.inbounds.delAllClientsConfirmTitle', { remark: dbInbound.remark, count }),
  339. content: t('pages.inbounds.delAllClientsConfirmContent'),
  340. okText: t('delete'),
  341. okType: 'danger',
  342. cancelText: t('cancel'),
  343. onOk: async () => {
  344. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delAllClients`);
  345. if (msg?.success) await refresh();
  346. },
  347. });
  348. }, [modal, refresh, t, clientCount]);
  349. const confirmClone = useCallback((dbInbound: DBInbound) => {
  350. modal.confirm({
  351. title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
  352. content: t('pages.inbounds.cloneConfirmContent'),
  353. okText: t('pages.inbounds.clone'),
  354. cancelText: t('cancel'),
  355. onOk: async () => {
  356. let clonedSettings: string;
  357. try {
  358. const raw = coerceInboundJsonField(dbInbound.settings);
  359. raw.clients = [];
  360. clonedSettings = JSON.stringify(raw);
  361. } catch {
  362. const fallback = createDefaultInboundSettings(dbInbound.protocol);
  363. clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
  364. }
  365. const streamSettingsString = typeof dbInbound.streamSettings === 'string'
  366. ? dbInbound.streamSettings
  367. : JSON.stringify(dbInbound.streamSettings ?? {});
  368. const sniffingString = typeof dbInbound.sniffing === 'string'
  369. ? dbInbound.sniffing
  370. : JSON.stringify(dbInbound.sniffing ?? {});
  371. const data = {
  372. up: 0,
  373. down: 0,
  374. total: 0,
  375. remark: `${dbInbound.remark} (clone)`,
  376. enable: false,
  377. expiryTime: 0,
  378. listen: '',
  379. port: RandomUtil.randomInteger(10000, 60000),
  380. protocol: dbInbound.protocol,
  381. settings: clonedSettings,
  382. streamSettings: streamSettingsString,
  383. sniffing: sniffingString,
  384. };
  385. const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
  386. if (msg?.success) await refresh();
  387. },
  388. });
  389. }, [modal, refresh, t]);
  390. const onGeneralAction = useCallback((key: GeneralAction) => {
  391. switch (key) {
  392. case 'import': importInbound(); break;
  393. case 'export': exportAllLinks(); break;
  394. case 'subs': exportAllSubs(); break;
  395. case 'resetInbounds':
  396. modal.confirm({
  397. title: 'Reset all inbound traffic?',
  398. okText: 'Reset',
  399. cancelText: 'Cancel',
  400. onOk: async () => {
  401. const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
  402. if (msg?.success) await refresh();
  403. },
  404. });
  405. break;
  406. default:
  407. messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
  408. }
  409. }, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
  410. const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
  411. // Actions that touch per-client secrets (uuid, password, flow, ...) need
  412. // the full payload that the slim list view does not ship. Hydrate first
  413. // and then operate on the rehydrated record.
  414. const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'addToGroup'];
  415. let target = dbInbound;
  416. if (hydratingKeys.includes(key)) {
  417. const hydrated = await hydrateInbound(dbInbound.id);
  418. if (hydrated) target = hydrated;
  419. }
  420. switch (key) {
  421. case 'edit':
  422. openEdit(target);
  423. break;
  424. case 'showInfo':
  425. setInfoDbInbound(checkFallback(target));
  426. setInfoClientIndex(findClientIndex(target, null));
  427. setInfoOpen(true);
  428. break;
  429. case 'qrcode':
  430. setQrDbInbound(checkFallback(target));
  431. setQrOpen(true);
  432. break;
  433. case 'export':
  434. exportInboundLinks(target);
  435. break;
  436. case 'subs':
  437. exportInboundSubs(target);
  438. break;
  439. case 'clipboard':
  440. exportInboundClipboard(target);
  441. break;
  442. case 'delete':
  443. confirmDelete(target);
  444. break;
  445. case 'resetTraffic':
  446. confirmResetTraffic(target);
  447. break;
  448. case 'delAllClients':
  449. confirmDelAllClients(target);
  450. break;
  451. case 'attachClients':
  452. setAttachSource(target);
  453. setAttachOpen(true);
  454. break;
  455. case 'detachClients':
  456. setDetachSource(target);
  457. setDetachOpen(true);
  458. break;
  459. case 'addToGroup':
  460. setGroupSource(target);
  461. setGroupOpen(true);
  462. break;
  463. case 'clone':
  464. confirmClone(target);
  465. break;
  466. default:
  467. messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
  468. }
  469. }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmDelAllClients, confirmClone, messageApi]);
  470. return (
  471. <ConfigProvider theme={antdThemeConfig}>
  472. {messageContextHolder}
  473. {modalContextHolder}
  474. <Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
  475. <AppSidebar />
  476. <Layout className="content-shell">
  477. <Layout.Content id="content-layout" className="content-area">
  478. <Spin spinning={!fetched} delay={200} description="Loading…" size="large">
  479. {!fetched ? (
  480. <div className="loading-spacer" />
  481. ) : (
  482. <Row gutter={[isMobile ? 8 : 16, 12]}>
  483. <Col span={24}>
  484. <Card size="small" hoverable className="summary-card">
  485. <Row gutter={[16, 12]}>
  486. <Col xs={12} sm={12} md={8}>
  487. <Statistic
  488. title={t('pages.inbounds.totalDownUp')}
  489. value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
  490. prefix={<SwapOutlined />}
  491. />
  492. </Col>
  493. <Col xs={12} sm={12} md={8}>
  494. <Statistic
  495. title={t('pages.inbounds.totalUsage')}
  496. value={SizeFormatter.sizeFormat(totals.up + totals.down)}
  497. prefix={<PieChartOutlined />}
  498. />
  499. </Col>
  500. <Col xs={24} sm={24} md={8}>
  501. <Statistic
  502. title={t('pages.inbounds.inboundCount')}
  503. value={String(dbInbounds.length)}
  504. prefix={<BarsOutlined />}
  505. />
  506. </Col>
  507. </Row>
  508. </Card>
  509. </Col>
  510. <Col span={24}>
  511. <InboundList
  512. dbInbounds={dbInbounds}
  513. clientCount={clientCount}
  514. onlineClients={onlineClients}
  515. lastOnlineMap={lastOnlineMap}
  516. expireDiff={expireDiff}
  517. trafficDiff={trafficDiff}
  518. pageSize={pageSize}
  519. isMobile={isMobile}
  520. subEnable={subSettings.enable}
  521. nodesById={nodesById}
  522. hasActiveNode={showNodeInfo}
  523. onAddInbound={onAddInbound}
  524. onGeneralAction={onGeneralAction}
  525. onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
  526. />
  527. </Col>
  528. </Row>
  529. )}
  530. </Spin>
  531. </Layout.Content>
  532. </Layout>
  533. <LazyMount when={formOpen}>
  534. <InboundFormModal
  535. open={formOpen}
  536. onClose={() => setFormOpen(false)}
  537. onSaved={refresh}
  538. mode={formMode}
  539. dbInbound={formDbInbound}
  540. dbInbounds={dbInbounds}
  541. availableNodes={nodesList}
  542. />
  543. </LazyMount>
  544. <LazyMount when={infoOpen}>
  545. <InboundInfoModal
  546. open={infoOpen}
  547. onClose={() => setInfoOpen(false)}
  548. dbInbound={infoDbInbound}
  549. clientIndex={infoClientIndex}
  550. remarkModel={remarkModel}
  551. expireDiff={expireDiff}
  552. trafficDiff={trafficDiff}
  553. ipLimitEnable={ipLimitEnable}
  554. tgBotEnable={tgBotEnable}
  555. subSettings={subSettings}
  556. lastOnlineMap={lastOnlineMap}
  557. nodeAddress={infoNodeAddress}
  558. />
  559. </LazyMount>
  560. <LazyMount when={qrOpen}>
  561. <QrCodeModal
  562. open={qrOpen}
  563. onClose={() => setQrOpen(false)}
  564. dbInbound={qrDbInbound}
  565. client={null}
  566. remarkModel={remarkModel}
  567. nodeAddress={qrNodeAddress}
  568. subSettings={subSettings}
  569. />
  570. </LazyMount>
  571. <LazyMount when={attachOpen}>
  572. <AttachClientsModal
  573. open={attachOpen}
  574. onClose={() => setAttachOpen(false)}
  575. onAttached={refresh}
  576. source={attachSource}
  577. dbInbounds={dbInbounds}
  578. />
  579. </LazyMount>
  580. <LazyMount when={detachOpen}>
  581. <DetachClientsModal
  582. open={detachOpen}
  583. onClose={() => setDetachOpen(false)}
  584. onDetached={refresh}
  585. source={detachSource}
  586. />
  587. </LazyMount>
  588. <LazyMount when={groupOpen}>
  589. <AddClientsToGroupModal
  590. open={groupOpen}
  591. onClose={() => setGroupOpen(false)}
  592. onAdded={refresh}
  593. source={groupSource}
  594. />
  595. </LazyMount>
  596. <LazyMount when={textOpen}>
  597. <TextModal
  598. open={textOpen}
  599. onClose={() => setTextOpen(false)}
  600. title={textTitle}
  601. content={textContent}
  602. fileName={textFileName}
  603. />
  604. </LazyMount>
  605. <LazyMount when={promptOpen}>
  606. <PromptModal
  607. open={promptOpen}
  608. onClose={() => setPromptOpen(false)}
  609. title={promptTitle}
  610. okText={promptOkText}
  611. type={promptType}
  612. initialValue={promptInitial}
  613. loading={promptLoading}
  614. onConfirm={onPromptConfirm}
  615. />
  616. </LazyMount>
  617. </Layout>
  618. </ConfigProvider>
  619. );
  620. }