InboundList.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. <script setup>
  2. import { computed, ref } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import {
  5. PlusOutlined,
  6. MenuOutlined,
  7. SearchOutlined,
  8. FilterOutlined,
  9. MoreOutlined,
  10. EditOutlined,
  11. QrcodeOutlined,
  12. UserAddOutlined,
  13. UsergroupAddOutlined,
  14. CopyOutlined,
  15. FileDoneOutlined,
  16. ExportOutlined,
  17. ImportOutlined,
  18. ReloadOutlined,
  19. RestOutlined,
  20. RetweetOutlined,
  21. BlockOutlined,
  22. DeleteOutlined,
  23. InfoCircleOutlined,
  24. RightOutlined,
  25. } from '@ant-design/icons-vue';
  26. import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
  27. import { DBInbound } from '@/models/dbinbound.js';
  28. import { Inbound } from '@/models/inbound.js';
  29. import InfinityIcon from '@/components/InfinityIcon.vue';
  30. import ClientRowTable from './ClientRowTable.vue';
  31. import { useDatepicker } from '@/composables/useDatepicker.js';
  32. const { datepicker } = useDatepicker();
  33. const { t } = useI18n();
  34. const props = defineProps({
  35. dbInbounds: { type: Array, required: true },
  36. clientCount: { type: Object, required: true },
  37. onlineClients: { type: Array, required: true },
  38. lastOnlineMap: { type: Object, default: () => ({}) },
  39. expireDiff: { type: Number, default: 0 },
  40. trafficDiff: { type: Number, default: 0 },
  41. pageSize: { type: Number, default: 0 },
  42. isMobile: { type: Boolean, default: false },
  43. isDarkTheme: { type: Boolean, default: false },
  44. subEnable: { type: Boolean, default: false },
  45. // Map node id -> node row, supplied by the parent page so each
  46. // inbound row can render its node name without an extra fetch.
  47. nodesById: { type: Map, default: () => new Map() },
  48. });
  49. const emit = defineEmits([
  50. 'refresh',
  51. 'add-inbound',
  52. 'general-action',
  53. 'row-action',
  54. // Per-client events surfaced from the expand-row table.
  55. 'edit-client',
  56. 'qrcode-client',
  57. 'info-client',
  58. 'reset-traffic-client',
  59. 'delete-client',
  60. 'delete-clients',
  61. 'toggle-enable-client',
  62. ]);
  63. // ============ Toolbar / search & filter =============================
  64. const enableFilter = ref(false);
  65. const searchKey = ref('');
  66. const filterBy = ref('');
  67. // Toggle the filter mode — flip cleans the other input.
  68. function onToggleFilter() {
  69. if (enableFilter.value) searchKey.value = '';
  70. else filterBy.value = '';
  71. }
  72. // ============ Search / filter projection =============================
  73. // Mirrors the legacy logic: when searching, keep inbounds that match
  74. // anywhere (deep search); when filtering, keep inbounds that have at
  75. // least one client in the requested bucket and reduce their settings
  76. // to that bucket.
  77. function projectInbound(dbInbound, predicate) {
  78. const next = new DBInbound(dbInbound);
  79. let settings;
  80. try {
  81. settings = JSON.parse(dbInbound.settings || '{}');
  82. } catch (_e) {
  83. settings = {};
  84. }
  85. if (!Array.isArray(settings.clients)) return next;
  86. const filtered = settings.clients.filter(predicate);
  87. next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
  88. next.invalidateCache();
  89. return next;
  90. }
  91. const visibleInbounds = computed(() => {
  92. if (enableFilter.value) {
  93. if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
  94. const out = [];
  95. for (const dbInbound of props.dbInbounds) {
  96. const c = props.clientCount[dbInbound.id];
  97. if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
  98. const list = c[filterBy.value];
  99. out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
  100. }
  101. return out;
  102. }
  103. if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
  104. const out = [];
  105. for (const dbInbound of props.dbInbounds) {
  106. if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
  107. out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
  108. }
  109. return out;
  110. });
  111. // ============ Columns =================================================
  112. // `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
  113. // `responsive` array still works on column defs. Computed so column
  114. // labels react to live locale switches.
  115. const desktopColumns = computed(() => {
  116. const cols = [
  117. { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30, responsive: ['xs'] },
  118. { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
  119. { title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
  120. { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
  121. ];
  122. if (props.nodesById.size > 0) {
  123. cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
  124. }
  125. cols.push(
  126. { title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
  127. { title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
  128. { title: t('clients'), key: 'clients', align: 'left', width: 50 },
  129. { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
  130. { title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
  131. { title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
  132. );
  133. return cols;
  134. });
  135. const columns = computed(() => desktopColumns.value);
  136. // Mobile expansion state — replaces a-table's expandable() since the
  137. // mobile branch renders a hand-rolled card list rather than a table.
  138. const expandedIds = ref(new Set());
  139. function toggleExpanded(id) {
  140. const next = new Set(expandedIds.value);
  141. if (next.has(id)) next.delete(id);
  142. else next.add(id);
  143. expandedIds.value = next;
  144. }
  145. function isExpanded(id) {
  146. return expandedIds.value.has(id);
  147. }
  148. // ============ Pagination ============================================
  149. function paginationFor(rows) {
  150. const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
  151. return {
  152. pageSize: size,
  153. showSizeChanger: false,
  154. hideOnSinglePage: true,
  155. };
  156. }
  157. // ============ Per-row enable switch =================================
  158. async function onSwitchEnable(dbInbound, next) {
  159. const previous = dbInbound.enable;
  160. dbInbound.enable = next; // optimistic
  161. try {
  162. const formData = new FormData();
  163. formData.append('enable', String(next));
  164. const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
  165. if (!msg?.success) dbInbound.enable = previous;
  166. } catch (_e) {
  167. dbInbound.enable = previous;
  168. }
  169. }
  170. // ============ Helpers shared with the templates =====================
  171. // Whether to show the "Switch xray" / qrcode menu entry — same predicate
  172. // as legacy: SS single-user inbounds and WireGuard inbounds expose
  173. // inbound-wide QR codes.
  174. function showQrCodeMenu(dbInbound) {
  175. if (dbInbound.isWireguard) return true;
  176. if (dbInbound.isSS) {
  177. try {
  178. return !dbInbound.toInbound().isSSMultiUser;
  179. } catch (_e) {
  180. return false;
  181. }
  182. }
  183. return false;
  184. }
  185. </script>
  186. <template>
  187. <a-card hoverable>
  188. <template #title>
  189. <a-space direction="horizontal">
  190. <a-button type="primary" @click="emit('add-inbound')">
  191. <template #icon>
  192. <PlusOutlined />
  193. </template>
  194. <template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
  195. </a-button>
  196. <a-dropdown :trigger="['click']">
  197. <a-button type="primary">
  198. <template #icon>
  199. <MenuOutlined />
  200. </template>
  201. <template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
  202. </a-button>
  203. <template #overlay>
  204. <a-menu @click="(a) => emit('general-action', a.key)">
  205. <a-menu-item key="import">
  206. <ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
  207. </a-menu-item>
  208. <a-menu-item key="export">
  209. <ExportOutlined /> {{ t('pages.inbounds.export') }}
  210. </a-menu-item>
  211. <a-menu-item v-if="subEnable" key="subs">
  212. <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
  213. </a-menu-item>
  214. <a-menu-item key="resetInbounds">
  215. <ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
  216. </a-menu-item>
  217. <a-menu-item key="resetClients">
  218. <FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
  219. </a-menu-item>
  220. <a-menu-item key="delDepletedClients" class="danger-item">
  221. <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
  222. </a-menu-item>
  223. </a-menu>
  224. </template>
  225. </a-dropdown>
  226. </a-space>
  227. </template>
  228. <a-space direction="vertical" :style="{ width: '100%' }">
  229. <!-- Search / filter toolbar -->
  230. <div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
  231. <a-switch v-model:checked="enableFilter" @change="onToggleFilter">
  232. <template #checkedChildren>
  233. <SearchOutlined />
  234. </template>
  235. <template #unCheckedChildren>
  236. <FilterOutlined />
  237. </template>
  238. </a-switch>
  239. <a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
  240. :size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
  241. <a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
  242. :size="isMobile ? 'small' : 'middle'">
  243. <a-radio-button value="">{{ t('none') }}</a-radio-button>
  244. <a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
  245. <a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
  246. <a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
  247. <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
  248. <a-radio-button value="online">{{ t('online') }}</a-radio-button>
  249. </a-radio-group>
  250. </div>
  251. <!-- ====================== Mobile: card list ======================= -->
  252. <div v-if="isMobile" class="inbound-cards">
  253. <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
  254. <div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
  255. <!-- Header: chevron (multi-user only) + remark + enable + actions -->
  256. <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
  257. <RightOutlined v-if="record.isMultiUser()" class="card-expand"
  258. :class="{ 'is-expanded': isExpanded(record.id) }" />
  259. <span class="card-id">#{{ record.id }}</span>
  260. <span class="tag-name">{{ record.remark }}</span>
  261. <div class="card-actions" @click.stop>
  262. <a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
  263. <a-dropdown :trigger="['click']" placement="bottomRight">
  264. <MoreOutlined class="row-action-trigger" @click.prevent />
  265. <template #overlay>
  266. <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
  267. <a-menu-item key="edit">
  268. <EditOutlined /> {{ t('edit') }}
  269. </a-menu-item>
  270. <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
  271. <QrcodeOutlined /> {{ t('qrCode') }}
  272. </a-menu-item>
  273. <template v-if="record.isMultiUser()">
  274. <a-menu-item key="addClient">
  275. <UserAddOutlined /> {{ t('pages.client.add') }}
  276. </a-menu-item>
  277. <a-menu-item key="addBulkClient">
  278. <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
  279. </a-menu-item>
  280. <a-menu-item key="copyClients">
  281. <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
  282. </a-menu-item>
  283. <a-menu-item key="resetClients">
  284. <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
  285. </a-menu-item>
  286. <a-menu-item key="export">
  287. <ExportOutlined /> {{ t('pages.inbounds.export') }}
  288. </a-menu-item>
  289. <a-menu-item v-if="subEnable" key="subs">
  290. <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
  291. </a-menu-item>
  292. <a-menu-item key="delDepletedClients" class="danger-item">
  293. <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
  294. </a-menu-item>
  295. </template>
  296. <template v-else>
  297. <a-menu-item key="showInfo">
  298. <InfoCircleOutlined /> {{ t('info') }}
  299. </a-menu-item>
  300. </template>
  301. <a-menu-item key="clipboard">
  302. <CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
  303. </a-menu-item>
  304. <a-menu-item key="resetTraffic">
  305. <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
  306. </a-menu-item>
  307. <a-menu-item key="clone">
  308. <BlockOutlined /> {{ t('pages.inbounds.clone') }}
  309. </a-menu-item>
  310. <a-menu-item key="delete" class="danger-item">
  311. <DeleteOutlined /> {{ t('delete') }}
  312. </a-menu-item>
  313. </a-menu>
  314. </template>
  315. </a-dropdown>
  316. </div>
  317. </div>
  318. <!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
  319. <div class="card-stats">
  320. <div class="stat-row">
  321. <span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
  322. <a-tag color="purple">{{ record.protocol }}</a-tag>
  323. <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
  324. <a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
  325. <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
  326. <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
  327. </template>
  328. </div>
  329. <div class="stat-row">
  330. <span class="stat-label">{{ t('pages.inbounds.port') }}</span>
  331. <a-tag>{{ record.port }}</a-tag>
  332. </div>
  333. <div v-if="nodesById.size > 0" class="stat-row">
  334. <span class="stat-label">{{ t('pages.inbounds.node') }}</span>
  335. <a-tag v-if="record.nodeId == null" color="default">
  336. {{ t('pages.inbounds.localPanel') }}
  337. </a-tag>
  338. <a-tag v-else-if="nodesById.get(record.nodeId)"
  339. :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
  340. {{ nodesById.get(record.nodeId).name }}
  341. </a-tag>
  342. <a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
  343. </div>
  344. <div class="stat-row">
  345. <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
  346. <a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
  347. {{ SizeFormatter.sizeFormat(record.up + record.down) }} /
  348. <template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
  349. <InfinityIcon v-else />
  350. </a-tag>
  351. </div>
  352. <div class="stat-row">
  353. <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
  354. <a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
  355. </div>
  356. <div v-if="clientCount[record.id]" class="stat-row">
  357. <span class="stat-label">{{ t('clients') }}</span>
  358. <a-tag color="green">{{ clientCount[record.id].clients }}</a-tag>
  359. <a-tag v-if="clientCount[record.id].online.length" color="blue">
  360. {{ clientCount[record.id].online.length }} {{ t('online') }}
  361. </a-tag>
  362. <a-tag v-if="clientCount[record.id].depleted.length" color="red">
  363. {{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
  364. </a-tag>
  365. <a-tag v-if="clientCount[record.id].expiring.length" color="orange">
  366. {{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
  367. </a-tag>
  368. </div>
  369. <div class="stat-row">
  370. <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
  371. <a-tag v-if="record.expiryTime > 0"
  372. :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
  373. {{ IntlUtil.formatRelativeTime(record.expiryTime) }}
  374. </a-tag>
  375. <a-tag v-else color="purple">
  376. <InfinityIcon />
  377. </a-tag>
  378. </div>
  379. </div>
  380. <!-- Expanded client list (multi-user only) -->
  381. <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
  382. <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
  383. :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
  384. @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
  385. @info-client="(p) => emit('info-client', p)"
  386. @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
  387. @delete-client="(p) => emit('delete-client', p)"
  388. @delete-clients="(p) => emit('delete-clients', p)"
  389. @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
  390. </div>
  391. </div>
  392. </div>
  393. <!-- ====================== Desktop: a-table ======================== -->
  394. <a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
  395. :pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
  396. :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
  397. <!-- Per-inbound client list, expanded by clicking the row's
  398. default expand chevron. Hidden via row-class-name for
  399. non-multi-user inbounds (matches legacy behavior). -->
  400. <template #expandedRowRender="{ record }">
  401. <ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
  402. :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
  403. :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" @edit-client="(p) => emit('edit-client', p)"
  404. @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
  405. @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
  406. @delete-client="(p) => emit('delete-client', p)"
  407. @delete-clients="(p) => emit('delete-clients', p)"
  408. @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
  409. </template>
  410. <template #bodyCell="{ column, record }">
  411. <!-- ============== Action dropdown ============== -->
  412. <template v-if="column.key === 'action'">
  413. <a-dropdown :trigger="['click']">
  414. <MoreOutlined class="row-action-trigger" @click.prevent />
  415. <template #overlay>
  416. <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
  417. <a-menu-item key="edit">
  418. <EditOutlined /> {{ t('edit') }}
  419. </a-menu-item>
  420. <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
  421. <QrcodeOutlined /> {{ t('qrCode') }}
  422. </a-menu-item>
  423. <template v-if="record.isMultiUser()">
  424. <a-menu-item key="addClient">
  425. <UserAddOutlined /> {{ t('pages.client.add') }}
  426. </a-menu-item>
  427. <a-menu-item key="addBulkClient">
  428. <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
  429. </a-menu-item>
  430. <a-menu-item key="copyClients">
  431. <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
  432. </a-menu-item>
  433. <a-menu-item key="resetClients">
  434. <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
  435. </a-menu-item>
  436. <a-menu-item key="export">
  437. <ExportOutlined /> {{ t('pages.inbounds.export') }}
  438. </a-menu-item>
  439. <a-menu-item v-if="subEnable" key="subs">
  440. <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
  441. </a-menu-item>
  442. <a-menu-item key="delDepletedClients" class="danger-item">
  443. <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
  444. </a-menu-item>
  445. </template>
  446. <template v-else>
  447. <a-menu-item key="showInfo">
  448. <InfoCircleOutlined /> {{ t('info') }}
  449. </a-menu-item>
  450. </template>
  451. <a-menu-item key="clipboard">
  452. <CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
  453. </a-menu-item>
  454. <a-menu-item key="resetTraffic">
  455. <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
  456. </a-menu-item>
  457. <a-menu-item key="clone">
  458. <BlockOutlined /> {{ t('pages.inbounds.clone') }}
  459. </a-menu-item>
  460. <a-menu-item key="delete" class="danger-item">
  461. <DeleteOutlined /> {{ t('delete') }}
  462. </a-menu-item>
  463. </a-menu>
  464. </template>
  465. </a-dropdown>
  466. </template>
  467. <!-- ============== Enable switch (desktop) ============== -->
  468. <template v-else-if="column.key === 'enable'">
  469. <a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
  470. </template>
  471. <!-- ============== Node deployment tag ============== -->
  472. <template v-else-if="column.key === 'node'">
  473. <template v-if="record.nodeId == null">
  474. <a-tag color="default">{{ t('pages.inbounds.localPanel') }}</a-tag>
  475. </template>
  476. <template v-else-if="nodesById.get(record.nodeId)">
  477. <a-tag :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
  478. {{ nodesById.get(record.nodeId).name }}
  479. </a-tag>
  480. </template>
  481. <template v-else>
  482. <!-- Node row was deleted but inbound still references it. -->
  483. <a-tag color="orange">node #{{ record.nodeId }}</a-tag>
  484. </template>
  485. </template>
  486. <!-- ============== Protocol tags ============== -->
  487. <template v-else-if="column.key === 'protocol'">
  488. <div class="protocol-tags">
  489. <a-tag color="purple">{{ record.protocol }}</a-tag>
  490. <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
  491. <a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
  492. <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
  493. <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
  494. </template>
  495. </div>
  496. </template>
  497. <!-- ============== Clients tag + popovers ============== -->
  498. <template v-else-if="column.key === 'clients'">
  499. <template v-if="clientCount[record.id]">
  500. <a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
  501. <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
  502. <template #content>
  503. <div class="client-email-list">
  504. <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
  505. </div>
  506. </template>
  507. <a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
  508. </a-popover>
  509. <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
  510. <template #content>
  511. <div class="client-email-list">
  512. <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
  513. </div>
  514. </template>
  515. <a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
  516. }}</a-tag>
  517. </a-popover>
  518. <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
  519. <template #content>
  520. <div class="client-email-list">
  521. <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
  522. </div>
  523. </template>
  524. <a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
  525. }}</a-tag>
  526. </a-popover>
  527. <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
  528. <template #content>
  529. <div class="client-email-list">
  530. <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
  531. </div>
  532. </template>
  533. <a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
  534. </a-popover>
  535. </template>
  536. </template>
  537. <!-- ============== Traffic ============== -->
  538. <template v-else-if="column.key === 'traffic'">
  539. <a-popover>
  540. <template #content>
  541. <table cellpadding="2">
  542. <tbody>
  543. <tr>
  544. <td>↑ {{ SizeFormatter.sizeFormat(record.up) }}</td>
  545. <td>↓ {{ SizeFormatter.sizeFormat(record.down) }}</td>
  546. </tr>
  547. <tr v-if="record.total > 0 && record.up + record.down < record.total">
  548. <td>{{ t('remained') }}</td>
  549. <td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
  550. </tr>
  551. </tbody>
  552. </table>
  553. </template>
  554. <a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
  555. {{ SizeFormatter.sizeFormat(record.up + record.down) }} /
  556. <template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
  557. <InfinityIcon v-else />
  558. </a-tag>
  559. </a-popover>
  560. </template>
  561. <!-- ============== All-time inbound traffic ============== -->
  562. <template v-else-if="column.key === 'allTimeInbound'">
  563. <a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
  564. </template>
  565. <!-- ============== Expiry ============== -->
  566. <template v-else-if="column.key === 'expiryTime'">
  567. <a-popover v-if="record.expiryTime > 0">
  568. <template #content>{{ IntlUtil.formatDate(record.expiryTime, datepicker) }}</template>
  569. <a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
  570. {{ IntlUtil.formatRelativeTime(record.expiryTime) }}
  571. </a-tag>
  572. </a-popover>
  573. <a-tag v-else color="purple">
  574. <InfinityIcon />
  575. </a-tag>
  576. </template>
  577. </template>
  578. </a-table>
  579. </a-space>
  580. </a-card>
  581. </template>
  582. <style scoped>
  583. .filter-bar {
  584. display: flex;
  585. align-items: center;
  586. gap: 8px;
  587. }
  588. .filter-bar.mobile {
  589. display: block;
  590. }
  591. .filter-bar.mobile>* {
  592. margin-bottom: 4px;
  593. }
  594. .protocol-tags {
  595. display: inline-flex;
  596. flex-wrap: wrap;
  597. gap: 4px;
  598. }
  599. .row-action-trigger {
  600. font-size: 20px;
  601. cursor: pointer;
  602. }
  603. .danger-item {
  604. color: #ff4d4f;
  605. }
  606. /* Hide the expand chevron on rows whose inbound has no client list
  607. * (HTTP/Mixed/Tunnel/WireGuard single-config). */
  608. :deep(.hide-expand-icon .ant-table-row-expand-icon) {
  609. visibility: hidden;
  610. }
  611. /* Push the expand chevron away from the table's left edge so it has
  612. * a little breathing room instead of being flush against the corner. */
  613. :deep(.ant-table-tbody .ant-table-cell-with-append) {
  614. padding-left: 12px;
  615. }
  616. :deep(.ant-table-row-expand-icon) {
  617. margin-inline-end: 10px;
  618. margin-inline-start: 4px;
  619. }
  620. /* Round the table's outer corners — AD-Vue gives .ant-table the radius
  621. * token, but the inner header strip and footer touch the edges, so clip
  622. * them here. */
  623. :deep(.ant-table) {
  624. border-radius: 8px;
  625. overflow: hidden;
  626. }
  627. :deep(.ant-table-container) {
  628. border-radius: 8px;
  629. overflow: hidden;
  630. }
  631. :deep(.ant-table-thead > tr:first-child > *:first-child) {
  632. border-start-start-radius: 8px;
  633. }
  634. :deep(.ant-table-thead > tr:first-child > *:last-child) {
  635. border-start-end-radius: 8px;
  636. }
  637. :deep(.ant-table-tbody > tr:last-child > *:first-child) {
  638. border-end-start-radius: 8px;
  639. }
  640. :deep(.ant-table-tbody > tr:last-child > *:last-child) {
  641. border-end-end-radius: 8px;
  642. }
  643. /* ===== Mobile card list ===========================================
  644. * <768px renders inbounds as a vertical stack of cards via the
  645. * v-if="isMobile" branch above; the desktop <a-table> isn't mounted
  646. * so the legacy table-cell tightening rules went away. */
  647. .inbound-cards {
  648. display: flex;
  649. flex-direction: column;
  650. gap: 12px;
  651. margin-top: 4px;
  652. }
  653. .inbound-card {
  654. border: 1px solid rgba(128, 128, 128, 0.2);
  655. border-radius: 10px;
  656. padding: 12px;
  657. background: rgba(255, 255, 255, 0.02);
  658. display: flex;
  659. flex-direction: column;
  660. gap: 8px;
  661. }
  662. :global(body.dark) .inbound-card {
  663. background: rgba(255, 255, 255, 0.03);
  664. border-color: rgba(255, 255, 255, 0.1);
  665. }
  666. .card-head {
  667. display: flex;
  668. align-items: center;
  669. gap: 8px;
  670. cursor: pointer;
  671. user-select: none;
  672. }
  673. .card-id {
  674. font-size: 11px;
  675. opacity: 0.6;
  676. }
  677. .tag-name {
  678. font-weight: 600;
  679. flex: 1;
  680. min-width: 0;
  681. overflow: hidden;
  682. text-overflow: ellipsis;
  683. white-space: nowrap;
  684. }
  685. .card-actions {
  686. display: flex;
  687. align-items: center;
  688. gap: 8px;
  689. flex-shrink: 0;
  690. }
  691. .card-expand {
  692. font-size: 12px;
  693. opacity: 0.6;
  694. transition: transform 150ms ease;
  695. flex-shrink: 0;
  696. }
  697. .card-expand.is-expanded {
  698. transform: rotate(90deg);
  699. }
  700. .card-stats {
  701. display: flex;
  702. flex-direction: column;
  703. gap: 6px;
  704. }
  705. .stat-row {
  706. display: flex;
  707. align-items: center;
  708. flex-wrap: wrap;
  709. gap: 6px;
  710. }
  711. .stat-label {
  712. font-size: 10px;
  713. text-transform: uppercase;
  714. letter-spacing: 0.04em;
  715. opacity: 0.6;
  716. min-width: 96px;
  717. flex-shrink: 0;
  718. }
  719. .card-stats :deep(.ant-tag) {
  720. margin: 0;
  721. }
  722. .card-clients {
  723. margin-top: 4px;
  724. padding-top: 8px;
  725. border-top: 1px solid rgba(128, 128, 128, 0.15);
  726. }
  727. .card-empty {
  728. text-align: center;
  729. opacity: 0.4;
  730. padding: 20px 0;
  731. }
  732. @media (max-width: 768px) {
  733. :deep(.ant-card-head) {
  734. padding: 0 12px;
  735. min-height: 44px;
  736. }
  737. :deep(.ant-card-head-title),
  738. :deep(.ant-card-extra) {
  739. padding: 8px 0;
  740. }
  741. :deep(.ant-card-body) {
  742. padding: 8px;
  743. }
  744. .filter-bar.mobile {
  745. display: flex;
  746. flex-wrap: wrap;
  747. gap: 6px;
  748. }
  749. .filter-bar.mobile>* {
  750. margin-bottom: 0;
  751. }
  752. .row-action-trigger {
  753. font-size: 22px;
  754. padding: 4px;
  755. }
  756. }
  757. </style>