InboundsPage.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. <script setup>
  2. import { computed, onMounted, ref } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { Modal, message } from 'ant-design-vue';
  5. import {
  6. SwapOutlined,
  7. PieChartOutlined,
  8. HistoryOutlined,
  9. BarsOutlined,
  10. TeamOutlined,
  11. } from '@ant-design/icons-vue';
  12. import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
  13. import { Inbound } from '@/models/inbound.js';
  14. import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
  15. import { useMediaQuery } from '@/composables/useMediaQuery.js';
  16. import AppSidebar from '@/components/AppSidebar.vue';
  17. import CustomStatistic from '@/components/CustomStatistic.vue';
  18. import { useNodeList } from '@/composables/useNodeList.js';
  19. import InboundList from './InboundList.vue';
  20. import InboundFormModal from './InboundFormModal.vue';
  21. import ClientFormModal from './ClientFormModal.vue';
  22. import ClientBulkModal from './ClientBulkModal.vue';
  23. import CopyClientsModal from './CopyClientsModal.vue';
  24. import InboundInfoModal from './InboundInfoModal.vue';
  25. import QrCodeModal from './QrCodeModal.vue';
  26. import TextModal from '@/components/TextModal.vue';
  27. import PromptModal from '@/components/PromptModal.vue';
  28. import { useInbounds } from './useInbounds.js';
  29. import { useWebSocket } from '@/composables/useWebSocket.js';
  30. const { t } = useI18n();
  31. const {
  32. fetched,
  33. dbInbounds,
  34. clientCount,
  35. onlineClients,
  36. totals,
  37. expireDiff,
  38. trafficDiff,
  39. pageSize,
  40. subSettings,
  41. tgBotEnable,
  42. ipLimitEnable,
  43. remarkModel,
  44. lastOnlineMap,
  45. refresh,
  46. fetchDefaultSettings,
  47. applyTrafficEvent,
  48. applyClientStatsEvent,
  49. applyInvalidate,
  50. applyInboundsEvent,
  51. } = useInbounds();
  52. // Live updates over WebSocket — replaces the old 5s polling loop.
  53. // The backend pushes traffic + per-client deltas every ~10s; we merge
  54. // them into the local refs in-place so counters and online badges
  55. // update without re-fetching the whole list.
  56. useWebSocket({
  57. traffic: applyTrafficEvent,
  58. client_stats: applyClientStatsEvent,
  59. invalidate: applyInvalidate,
  60. inbounds: applyInboundsEvent,
  61. });
  62. const { isMobile } = useMediaQuery();
  63. // Node list lives on the central panel; the Inbounds page consumes
  64. // the id→node map for the new "Node" column. Fetched once on mount.
  65. const { byId: nodesById } = useNodeList();
  66. const basePath = window.X_UI_BASE_PATH || '';
  67. const requestUri = window.location.pathname;
  68. onMounted(async () => {
  69. await fetchDefaultSettings();
  70. await refresh();
  71. });
  72. // === Add/Edit modal ===================================================
  73. const formOpen = ref(false);
  74. const formMode = ref('add');
  75. const formDbInbound = ref(null);
  76. // === Client modal (single + bulk) =====================================
  77. const clientOpen = ref(false);
  78. const clientMode = ref('add');
  79. const clientDbInbound = ref(null);
  80. const clientIndex = ref(null);
  81. const bulkOpen = ref(false);
  82. const bulkDbInbound = ref(null);
  83. const copyOpen = ref(false);
  84. const copyDbInbound = ref(null);
  85. // === Info / QR-code modals ===========================================
  86. const infoOpen = ref(false);
  87. const infoDbInbound = ref(null);
  88. const infoClientIndex = ref(0);
  89. const qrOpen = ref(false);
  90. const qrDbInbound = ref(null);
  91. const qrClient = ref(null);
  92. // hostOverrideFor returns the node's address for a node-managed inbound,
  93. // or '' when the inbound runs locally. Wired into the QR / Info modals
  94. // and into export-all-links functions so generated share links point at
  95. // the node, not the central panel.
  96. function hostOverrideFor(dbInbound) {
  97. if (!dbInbound || dbInbound.nodeId == null) return '';
  98. return nodesById.value.get(dbInbound.nodeId)?.address || '';
  99. }
  100. const infoNodeAddress = computed(() => hostOverrideFor(infoDbInbound.value));
  101. const qrNodeAddress = computed(() => hostOverrideFor(qrDbInbound.value));
  102. // === Shared text + prompt modal state =================================
  103. const textOpen = ref(false);
  104. const textTitle = ref('');
  105. const textContent = ref('');
  106. const textFileName = ref('');
  107. const promptOpen = ref(false);
  108. const promptTitle = ref('');
  109. const promptOkText = ref('OK');
  110. const promptType = ref('textarea');
  111. const promptInitial = ref('');
  112. const promptLoading = ref(false);
  113. let promptHandler = null;
  114. function openText({ title, content, fileName = '' }) {
  115. textTitle.value = title;
  116. textContent.value = content;
  117. textFileName.value = fileName;
  118. textOpen.value = true;
  119. }
  120. function openPrompt({ title, okText, type = 'textarea', value = '', confirm }) {
  121. promptTitle.value = title;
  122. promptOkText.value = okText || 'OK';
  123. promptType.value = type;
  124. promptInitial.value = value;
  125. promptHandler = confirm;
  126. promptOpen.value = true;
  127. }
  128. async function onPromptConfirm(value) {
  129. if (!promptHandler) { promptOpen.value = false; return; }
  130. promptLoading.value = true;
  131. try {
  132. const ok = await promptHandler(value);
  133. if (ok !== false) promptOpen.value = false;
  134. } finally {
  135. promptLoading.value = false;
  136. }
  137. }
  138. // === Export helpers — mirror legacy txtModal call sites ==============
  139. function exportInboundLinks(dbInbound) {
  140. const projected = checkFallback(dbInbound);
  141. openText({
  142. title: 'Export inbound links',
  143. content: projected.genInboundLinks(remarkModel.value, hostOverrideFor(dbInbound)),
  144. fileName: projected.remark || 'inbound',
  145. });
  146. }
  147. function exportInboundClipboard(dbInbound) {
  148. openText({
  149. title: 'Inbound JSON',
  150. content: JSON.stringify(dbInbound, null, 2),
  151. });
  152. }
  153. function exportInboundSubs(dbInbound) {
  154. const inbound = dbInbound.toInbound();
  155. const clients = inbound?.clients || [];
  156. const subLinks = [];
  157. for (const c of clients) {
  158. if (c.subId && subSettings.value.subURI) {
  159. subLinks.push(subSettings.value.subURI + c.subId);
  160. }
  161. }
  162. openText({
  163. title: 'Export subscription links',
  164. content: [...new Set(subLinks)].join('\n'),
  165. fileName: `${dbInbound.remark || 'inbound'}-Subs`,
  166. });
  167. }
  168. function exportAllLinks() {
  169. const out = [];
  170. for (const ib of dbInbounds.value) {
  171. out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
  172. }
  173. openText({
  174. title: 'Export all inbound links',
  175. content: out.join('\r\n'),
  176. fileName: 'All-Inbounds',
  177. });
  178. }
  179. function exportAllSubs() {
  180. const out = [];
  181. for (const ib of dbInbounds.value) {
  182. const inbound = ib.toInbound();
  183. const clients = inbound?.clients || [];
  184. for (const c of clients) {
  185. if (c.subId && subSettings.value.subURI) {
  186. out.push(subSettings.value.subURI + c.subId);
  187. }
  188. }
  189. }
  190. openText({
  191. title: 'Export all subscription links',
  192. content: [...new Set(out)].join('\r\n'),
  193. fileName: 'All-Inbounds-Subs',
  194. });
  195. }
  196. function importInbound() {
  197. openPrompt({
  198. title: 'Import inbound',
  199. okText: 'Import',
  200. type: 'textarea',
  201. value: '',
  202. confirm: async (value) => {
  203. const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
  204. if (msg?.success) {
  205. await refresh();
  206. return true;
  207. }
  208. return false;
  209. },
  210. });
  211. }
  212. // `checkFallback` mirrors the legacy helper: when an inbound listens
  213. // on a unix-socket fallback (`@<name>`), point the link generator at
  214. // the root inbound that owns the listen address so QRs/links carry
  215. // the externally-reachable host:port and the right TLS state.
  216. function checkFallback(dbInbound) {
  217. // We don't keep parsed Inbounds in state right now (the page works
  218. // off DBInbounds); compute on the fly.
  219. if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
  220. for (const candidate of dbInbounds.value) {
  221. if (candidate.id === dbInbound.id) continue;
  222. const parsed = candidate.toInbound();
  223. if (!parsed.isTcp) continue;
  224. if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
  225. const fallbacks = parsed.settings.fallbacks || [];
  226. if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
  227. // Build a one-off DBInbound copy with the parent's listen/port +
  228. // copied stream so the link gen sees the public endpoint.
  229. const projected = JSON.parse(JSON.stringify(dbInbound));
  230. projected.listen = candidate.listen;
  231. projected.port = candidate.port;
  232. const inheritedStream = parsed.stream;
  233. const ownInbound = dbInbound.toInbound();
  234. ownInbound.stream.security = inheritedStream.security;
  235. ownInbound.stream.tls = inheritedStream.tls;
  236. ownInbound.stream.externalProxy = inheritedStream.externalProxy;
  237. projected.streamSettings = ownInbound.stream.toString();
  238. // Re-wrap so callers get the same DBInbound shape they had.
  239. return new dbInbound.constructor(projected);
  240. }
  241. return dbInbound;
  242. }
  243. function findClientIndex(dbInbound, client) {
  244. if (!client) return 0;
  245. const inbound = dbInbound.toInbound();
  246. const clients = inbound?.clients || [];
  247. const idx = clients.findIndex((c) => {
  248. if (!c) return false;
  249. switch (dbInbound.protocol) {
  250. case 'trojan':
  251. case 'shadowsocks':
  252. return c.password === client.password && c.email === client.email;
  253. default:
  254. return c.id === client.id && c.email === client.email;
  255. }
  256. });
  257. return idx >= 0 ? idx : 0;
  258. }
  259. function getClientId(protocol, client) {
  260. switch (protocol) {
  261. case 'trojan': return client.password;
  262. case 'shadowsocks': return client.email;
  263. case 'hysteria': return client.auth;
  264. default: return client.id;
  265. }
  266. }
  267. // === Per-client handlers (called from the expand-row table) =========
  268. function onEditClient({ dbInbound, client }) {
  269. clientMode.value = 'edit';
  270. clientDbInbound.value = dbInbound;
  271. clientIndex.value = findClientIndex(dbInbound, client);
  272. clientOpen.value = true;
  273. }
  274. function onQrcodeClient({ dbInbound, client }) {
  275. qrDbInbound.value = checkFallback(dbInbound);
  276. qrClient.value = client || null;
  277. qrOpen.value = true;
  278. }
  279. function onInfoClient({ dbInbound, client }) {
  280. infoDbInbound.value = checkFallback(dbInbound);
  281. infoClientIndex.value = findClientIndex(dbInbound, client);
  282. infoOpen.value = true;
  283. }
  284. async function onResetTrafficClient({ dbInbound, client }) {
  285. const msg = await HttpUtil.post(
  286. `/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
  287. );
  288. if (msg?.success) await refresh();
  289. }
  290. async function onDeleteClient({ dbInbound, client }) {
  291. const clientId = getClientId(dbInbound.protocol, client);
  292. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
  293. if (msg?.success) await refresh();
  294. }
  295. async function onDeleteClients({ dbInbound, clients }) {
  296. for (const client of clients) {
  297. const clientId = getClientId(dbInbound.protocol, client);
  298. await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
  299. }
  300. await refresh();
  301. }
  302. async function onToggleEnableClient({ dbInbound, client, next }) {
  303. // Mirror legacy: clone the parsed inbound, flip enable on the matching
  304. // client, and post the whole client back through updateClient. This
  305. // keeps the wire shape identical to the modal save path.
  306. const inbound = dbInbound.toInbound();
  307. const clients = inbound?.clients || [];
  308. const idx = findClientIndex(dbInbound, client);
  309. if (idx < 0 || !clients[idx]) return;
  310. clients[idx].enable = next;
  311. const clientId = getClientId(dbInbound.protocol, clients[idx]);
  312. const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
  313. id: dbInbound.id,
  314. settings: `{"clients": [${clients[idx].toString()}]}`,
  315. });
  316. if (msg?.success) await refresh();
  317. }
  318. function onAddInbound() {
  319. formMode.value = 'add';
  320. formDbInbound.value = null;
  321. formOpen.value = true;
  322. }
  323. function openEdit(dbInbound) {
  324. formMode.value = 'edit';
  325. formDbInbound.value = dbInbound;
  326. formOpen.value = true;
  327. }
  328. function openAddClient(dbInbound) {
  329. clientMode.value = 'add';
  330. clientDbInbound.value = dbInbound;
  331. clientIndex.value = null;
  332. clientOpen.value = true;
  333. }
  334. function openAddBulkClient(dbInbound) {
  335. bulkDbInbound.value = dbInbound;
  336. bulkOpen.value = true;
  337. }
  338. // Per-row destructive actions go through Modal.confirm (matches legacy).
  339. function confirmDelete(dbInbound) {
  340. Modal.confirm({
  341. title: `Delete inbound "${dbInbound.remark}"?`,
  342. content: 'This removes the inbound and all its clients. This cannot be undone.',
  343. okText: 'Delete',
  344. okType: 'danger',
  345. cancelText: 'Cancel',
  346. onOk: async () => {
  347. const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
  348. if (msg?.success) await refresh();
  349. },
  350. });
  351. }
  352. function confirmResetTraffic(dbInbound) {
  353. Modal.confirm({
  354. title: `Reset traffic for "${dbInbound.remark}"?`,
  355. content: 'Resets up/down counters to 0 for this inbound.',
  356. okText: 'Reset',
  357. cancelText: 'Cancel',
  358. onOk: async () => {
  359. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
  360. if (msg?.success) await refresh();
  361. },
  362. });
  363. }
  364. function confirmDelDepleted(dbInboundId) {
  365. Modal.confirm({
  366. title: 'Delete depleted clients?',
  367. content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
  368. okText: 'Delete',
  369. okType: 'danger',
  370. cancelText: 'Cancel',
  371. onOk: async () => {
  372. const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
  373. if (msg?.success) await refresh();
  374. },
  375. });
  376. }
  377. // Clone — adds a new inbound with the same protocol+stream+sniffing
  378. // but a fresh remark/port and an empty client list.
  379. function confirmClone(dbInbound) {
  380. Modal.confirm({
  381. title: `Clone inbound "${dbInbound.remark}"?`,
  382. content: 'Creates a copy with a new port and an empty client list.',
  383. okText: 'Clone',
  384. cancelText: 'Cancel',
  385. onOk: async () => {
  386. const baseInbound = dbInbound.toInbound();
  387. const data = {
  388. up: 0,
  389. down: 0,
  390. total: 0,
  391. remark: `${dbInbound.remark} (clone)`,
  392. enable: false,
  393. expiryTime: 0,
  394. listen: '',
  395. port: RandomUtil.randomInteger(10000, 60000),
  396. protocol: baseInbound.protocol,
  397. settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
  398. streamSettings: baseInbound.stream.toString(),
  399. sniffing: baseInbound.sniffing.toString(),
  400. };
  401. const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
  402. if (msg?.success) await refresh();
  403. },
  404. });
  405. }
  406. function onGeneralAction(key) {
  407. switch (key) {
  408. case 'import':
  409. importInbound();
  410. break;
  411. case 'export':
  412. exportAllLinks();
  413. break;
  414. case 'subs':
  415. exportAllSubs();
  416. break;
  417. case 'resetInbounds':
  418. Modal.confirm({
  419. title: 'Reset all inbound traffic?',
  420. okText: 'Reset',
  421. cancelText: 'Cancel',
  422. onOk: async () => {
  423. const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
  424. if (msg?.success) await refresh();
  425. },
  426. });
  427. break;
  428. case 'resetClients':
  429. Modal.confirm({
  430. title: 'Reset all client traffic across all inbounds?',
  431. okText: 'Reset',
  432. cancelText: 'Cancel',
  433. onOk: async () => {
  434. const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
  435. if (msg?.success) await refresh();
  436. },
  437. });
  438. break;
  439. case 'delDepletedClients':
  440. confirmDelDepleted(-1);
  441. break;
  442. default:
  443. message.info(`General action "${key}" — coming in a later 5f subphase`);
  444. }
  445. }
  446. function onRowAction({ key, dbInbound }) {
  447. switch (key) {
  448. case 'edit':
  449. openEdit(dbInbound);
  450. break;
  451. case 'addClient':
  452. openAddClient(dbInbound);
  453. break;
  454. case 'addBulkClient':
  455. openAddBulkClient(dbInbound);
  456. break;
  457. case 'showInfo':
  458. infoDbInbound.value = checkFallback(dbInbound);
  459. infoClientIndex.value = findClientIndex(dbInbound, null);
  460. infoOpen.value = true;
  461. break;
  462. case 'qrcode':
  463. qrDbInbound.value = checkFallback(dbInbound);
  464. qrClient.value = null;
  465. qrOpen.value = true;
  466. break;
  467. case 'export':
  468. exportInboundLinks(dbInbound);
  469. break;
  470. case 'subs':
  471. exportInboundSubs(dbInbound);
  472. break;
  473. case 'clipboard':
  474. exportInboundClipboard(dbInbound);
  475. break;
  476. case 'copyClients':
  477. copyDbInbound.value = dbInbound;
  478. copyOpen.value = true;
  479. break;
  480. case 'delete':
  481. confirmDelete(dbInbound);
  482. break;
  483. case 'resetTraffic':
  484. confirmResetTraffic(dbInbound);
  485. break;
  486. case 'clone':
  487. confirmClone(dbInbound);
  488. break;
  489. case 'resetClients':
  490. Modal.confirm({
  491. title: `Reset client traffic on "${dbInbound.remark}"?`,
  492. okText: 'Reset',
  493. cancelText: 'Cancel',
  494. onOk: async () => {
  495. const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
  496. if (msg?.success) await refresh();
  497. },
  498. });
  499. break;
  500. case 'delDepletedClients':
  501. confirmDelDepleted(dbInbound.id);
  502. break;
  503. default:
  504. message.info(`Action "${key}" — coming in a later 5f subphase`);
  505. }
  506. }
  507. </script>
  508. <template>
  509. <a-config-provider :theme="antdThemeConfig">
  510. <a-layout class="inbounds-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
  511. <AppSidebar :base-path="basePath" :request-uri="requestUri" />
  512. <a-layout class="content-shell">
  513. <a-layout-content id="content-layout" class="content-area">
  514. <a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
  515. <div v-if="!fetched" class="loading-spacer" />
  516. <a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
  517. <!-- Summary statistics card -->
  518. <a-col :span="24">
  519. <a-card size="small" hoverable class="summary-card">
  520. <a-row :gutter="[16, 12]">
  521. <a-col :xs="12" :sm="12" :md="5">
  522. <CustomStatistic :title="t('pages.inbounds.totalDownUp')"
  523. :value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
  524. <template #prefix>
  525. <SwapOutlined />
  526. </template>
  527. </CustomStatistic>
  528. </a-col>
  529. <a-col :xs="12" :sm="12" :md="5">
  530. <CustomStatistic :title="t('pages.inbounds.totalUsage')"
  531. :value="SizeFormatter.sizeFormat(totals.up + totals.down)">
  532. <template #prefix>
  533. <PieChartOutlined />
  534. </template>
  535. </CustomStatistic>
  536. </a-col>
  537. <a-col :xs="12" :sm="12" :md="5">
  538. <CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
  539. :value="SizeFormatter.sizeFormat(totals.allTime)">
  540. <template #prefix>
  541. <HistoryOutlined />
  542. </template>
  543. </CustomStatistic>
  544. </a-col>
  545. <a-col :xs="12" :sm="12" :md="5">
  546. <CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
  547. <template #prefix>
  548. <BarsOutlined />
  549. </template>
  550. </CustomStatistic>
  551. </a-col>
  552. <a-col :xs="24" :sm="24" :md="4">
  553. <CustomStatistic :title="t('clients')" value=" ">
  554. <template #prefix>
  555. <a-space direction="horizontal">
  556. <TeamOutlined />
  557. <a-tag color="green">{{ totals.clients }}</a-tag>
  558. <a-popover v-if="totals.deactive.length" :title="t('disabled')">
  559. <template #content>
  560. <div class="client-email-list">
  561. <div v-for="email in totals.deactive" :key="email">{{ email }}</div>
  562. </div>
  563. </template>
  564. <a-tag>{{ totals.deactive.length }}</a-tag>
  565. </a-popover>
  566. <a-popover v-if="totals.depleted.length" :title="t('depleted')">
  567. <template #content>
  568. <div class="client-email-list">
  569. <div v-for="email in totals.depleted" :key="email">{{ email }}</div>
  570. </div>
  571. </template>
  572. <a-tag color="red">{{ totals.depleted.length }}</a-tag>
  573. </a-popover>
  574. <a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
  575. <template #content>
  576. <div class="client-email-list">
  577. <div v-for="email in totals.expiring" :key="email">{{ email }}</div>
  578. </div>
  579. </template>
  580. <a-tag color="orange">{{ totals.expiring.length }}</a-tag>
  581. </a-popover>
  582. <a-popover v-if="totals.online.length" :title="t('online')">
  583. <template #content>
  584. <div class="client-email-list">
  585. <div v-for="email in totals.online" :key="email">{{ email }}</div>
  586. </div>
  587. </template>
  588. <a-tag color="blue">{{ totals.online.length }}</a-tag>
  589. </a-popover>
  590. </a-space>
  591. </template>
  592. </CustomStatistic>
  593. </a-col>
  594. </a-row>
  595. </a-card>
  596. </a-col>
  597. <!-- Inbound list — toolbar, search/filter, columns, row actions -->
  598. <a-col :span="24">
  599. <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
  600. :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
  601. :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
  602. :sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
  603. @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
  604. @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
  605. @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
  606. @delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
  607. </a-col>
  608. </a-row>
  609. </a-spin>
  610. </a-layout-content>
  611. </a-layout>
  612. <InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
  613. <ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
  614. :client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
  615. :ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
  616. <ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
  617. :tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
  618. <CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
  619. @saved="refresh" />
  620. <InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
  621. :remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
  622. :ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
  623. :last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
  624. <QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
  625. :node-address="qrNodeAddress" :sub-settings="subSettings" />
  626. <TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
  627. <PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
  628. :initial-value="promptInitial" :loading="promptLoading" @confirm="onPromptConfirm" />
  629. </a-layout>
  630. </a-config-provider>
  631. </template>
  632. <style scoped>
  633. .inbounds-page {
  634. --bg-page: #e6e8ec;
  635. --bg-card: #ffffff;
  636. min-height: 100vh;
  637. background: var(--bg-page);
  638. }
  639. .inbounds-page.is-dark {
  640. --bg-page: #1e1e1e;
  641. --bg-card: #252526;
  642. }
  643. .inbounds-page.is-dark.is-ultra {
  644. --bg-page: #050505;
  645. --bg-card: #0c0e12;
  646. }
  647. .inbounds-page :deep(.ant-layout),
  648. .inbounds-page :deep(.ant-layout-content) {
  649. background: transparent;
  650. }
  651. .content-shell {
  652. background: transparent;
  653. }
  654. .content-area {
  655. padding: 24px;
  656. }
  657. @media (max-width: 768px) {
  658. .content-area {
  659. padding: 8px;
  660. }
  661. }
  662. .loading-spacer {
  663. min-height: calc(100vh - 120px);
  664. }
  665. .summary-card {
  666. padding: 16px;
  667. }
  668. @media (max-width: 768px) {
  669. .summary-card {
  670. padding: 8px;
  671. }
  672. }
  673. </style>
  674. <style>
  675. /* AD-Vue popovers teleport their content to <body>, so scoped styles
  676. don't reach them — this block has to be unscoped. */
  677. .client-email-list {
  678. max-height: 280px;
  679. min-width: 160px;
  680. overflow-y: auto;
  681. padding-right: 4px;
  682. }
  683. .client-email-list > div {
  684. padding: 2px 0;
  685. font-size: 12px;
  686. white-space: nowrap;
  687. }
  688. </style>