InboundList.vue 37 KB

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