ClientsPage.vue 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098
  1. <script setup>
  2. import { computed, ref, watch } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { Modal, message } from 'ant-design-vue';
  5. import {
  6. PlusOutlined,
  7. UserOutlined,
  8. EditOutlined,
  9. DeleteOutlined,
  10. InfoCircleOutlined,
  11. QrcodeOutlined,
  12. RetweetOutlined,
  13. RestOutlined,
  14. MoreOutlined,
  15. UsergroupAddOutlined,
  16. SearchOutlined,
  17. FilterOutlined,
  18. TeamOutlined,
  19. } from '@ant-design/icons-vue';
  20. import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
  21. import { useMediaQuery } from '@/composables/useMediaQuery.js';
  22. import { useWebSocket } from '@/composables/useWebSocket.js';
  23. import AppSidebar from '@/components/AppSidebar.vue';
  24. import CustomStatistic from '@/components/CustomStatistic.vue';
  25. import { ObjectUtil, SizeFormatter, IntlUtil } from '@/utils';
  26. import { useClients } from './useClients.js';
  27. import ClientFormModal from './ClientFormModal.vue';
  28. import ClientInfoModal from './ClientInfoModal.vue';
  29. import ClientQrModal from './ClientQrModal.vue';
  30. import ClientBulkAddModal from './ClientBulkAddModal.vue';
  31. const { t } = useI18n();
  32. const {
  33. clients,
  34. inbounds,
  35. onlines,
  36. loading,
  37. fetched,
  38. subSettings,
  39. ipLimitEnable,
  40. tgBotEnable,
  41. expireDiff,
  42. trafficDiff,
  43. pageSize,
  44. create,
  45. update,
  46. remove,
  47. removeMany,
  48. attach,
  49. detach,
  50. resetTraffic,
  51. resetAllTraffics,
  52. delDepleted,
  53. setEnable,
  54. applyTrafficEvent,
  55. applyClientStatsEvent,
  56. applyInvalidate,
  57. } = useClients();
  58. useWebSocket({
  59. traffic: applyTrafficEvent,
  60. client_stats: applyClientStatsEvent,
  61. invalidate: applyInvalidate,
  62. });
  63. const togglingEmail = ref(null);
  64. async function onToggleEnable(row, next) {
  65. togglingEmail.value = row.email;
  66. try {
  67. const msg = await setEnable(row, next);
  68. if (!msg?.success) {
  69. message.error(msg?.msg || t('somethingWentWrong'));
  70. }
  71. } finally {
  72. togglingEmail.value = null;
  73. }
  74. }
  75. const { isMobile } = useMediaQuery();
  76. const basePath = window.X_UI_BASE_PATH || '';
  77. const requestUri = window.location.pathname;
  78. const formOpen = ref(false);
  79. const formMode = ref('add');
  80. const editingClient = ref(null);
  81. const editingAttachedIds = ref([]);
  82. const infoOpen = ref(false);
  83. const infoClient = ref(null);
  84. const qrOpen = ref(false);
  85. const qrClient = ref(null);
  86. const bulkAddOpen = ref(false);
  87. const selectedRowKeys = ref([]);
  88. const rowSelection = computed(() => ({
  89. selectedRowKeys: selectedRowKeys.value,
  90. onChange: (keys) => { selectedRowKeys.value = keys; },
  91. }));
  92. function toggleSelect(email, checked) {
  93. const cur = new Set(selectedRowKeys.value);
  94. if (checked) cur.add(email);
  95. else cur.delete(email);
  96. selectedRowKeys.value = Array.from(cur);
  97. }
  98. function isSelected(email) {
  99. return selectedRowKeys.value.includes(email);
  100. }
  101. function selectAll(checked) {
  102. selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.email) : [];
  103. }
  104. const allSelected = computed(
  105. () => filteredClients.value.length > 0 && selectedRowKeys.value.length === filteredClients.value.length,
  106. );
  107. const someSelected = computed(
  108. () => selectedRowKeys.value.length > 0 && selectedRowKeys.value.length < filteredClients.value.length,
  109. );
  110. function onBulkAdd() {
  111. bulkAddOpen.value = true;
  112. }
  113. function onBulkDelete() {
  114. const emails = [...selectedRowKeys.value];
  115. if (emails.length === 0) return;
  116. Modal.confirm({
  117. title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
  118. content: t('pages.clients.bulkDeleteConfirmContent'),
  119. okText: t('delete'),
  120. okType: 'danger',
  121. cancelText: t('cancel'),
  122. onOk: async () => {
  123. const results = await removeMany(emails);
  124. selectedRowKeys.value = [];
  125. let ok = 0;
  126. let failed = 0;
  127. let firstError = '';
  128. for (const msg of results) {
  129. if (msg?.success) ok++;
  130. else {
  131. failed++;
  132. if (!firstError && msg?.msg) firstError = msg.msg;
  133. }
  134. }
  135. if (failed === 0) {
  136. message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
  137. } else {
  138. message.warning(firstError
  139. ? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
  140. : t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
  141. }
  142. },
  143. });
  144. }
  145. async function onBulkAddSaved() {
  146. bulkAddOpen.value = false;
  147. }
  148. function onDelDepleted() {
  149. Modal.confirm({
  150. title: t('pages.clients.delDepletedConfirmTitle'),
  151. content: t('pages.clients.delDepletedConfirmContent'),
  152. okText: t('delete'),
  153. okType: 'danger',
  154. cancelText: t('cancel'),
  155. onOk: async () => {
  156. const msg = await delDepleted();
  157. if (msg?.success) {
  158. const deleted = msg.obj?.deleted ?? 0;
  159. message.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
  160. }
  161. },
  162. });
  163. }
  164. const FILTER_STATE_KEY = 'clientsFilterState';
  165. const savedFilterState = (() => {
  166. try { return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); }
  167. catch (_e) { return {}; }
  168. })();
  169. const enableFilter = ref(!!savedFilterState.enableFilter);
  170. const searchKey = ref(savedFilterState.searchKey || '');
  171. const filterBy = ref(savedFilterState.filterBy || '');
  172. const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
  173. watch([enableFilter, searchKey, filterBy, protocolFilter], () => {
  174. localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
  175. enableFilter: enableFilter.value,
  176. searchKey: searchKey.value,
  177. filterBy: filterBy.value,
  178. protocolFilter: protocolFilter.value,
  179. }));
  180. });
  181. function onToggleFilter() {
  182. if (enableFilter.value) searchKey.value = '';
  183. else filterBy.value = '';
  184. }
  185. const protocolOptions = computed(() => {
  186. const values = new Set((inbounds.value || []).map((i) => i.protocol).filter(Boolean));
  187. return [...values].sort();
  188. });
  189. const onlineSet = computed(() => new Set(onlines.value || []));
  190. const inboundsById = computed(() => {
  191. const out = {};
  192. for (const ib of inbounds.value) out[ib.id] = ib;
  193. return out;
  194. });
  195. function isOnline(email) {
  196. return !!email && onlineSet.value.has(email);
  197. }
  198. function inboundLabel(id) {
  199. const ib = inboundsById.value[id];
  200. if (!ib) return `#${id}`;
  201. return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
  202. }
  203. function clientBucket(row) {
  204. if (!row) return null;
  205. const traffic = row.traffic || {};
  206. const used = (traffic.up || 0) + (traffic.down || 0);
  207. const total = row.totalGB || 0;
  208. const now = Date.now();
  209. const expired = row.expiryTime > 0 && row.expiryTime <= now;
  210. const exhausted = total > 0 && used >= total;
  211. if (expired || exhausted) return 'depleted';
  212. if (!row.enable) return 'deactive';
  213. const nearExpiry = row.expiryTime > 0 && row.expiryTime - now < (expireDiff.value || 0);
  214. const nearLimit = total > 0 && total - used < (trafficDiff.value || 0);
  215. if (nearExpiry || nearLimit) return 'expiring';
  216. return 'active';
  217. }
  218. function bucketTagColor(bucket) {
  219. switch (bucket) {
  220. case 'depleted': return 'red';
  221. case 'expiring': return 'orange';
  222. case 'deactive': return 'default';
  223. case 'active': return 'green';
  224. default: return 'default';
  225. }
  226. }
  227. function clientMatchesProtocol(row, protocol) {
  228. if (!protocol) return true;
  229. const ids = Array.isArray(row.inboundIds) ? row.inboundIds : [];
  230. for (const id of ids) {
  231. const ib = inboundsById.value[id];
  232. if (ib && ib.protocol === protocol) return true;
  233. }
  234. return false;
  235. }
  236. const filteredClients = computed(() => {
  237. let rows = clients.value || [];
  238. if (enableFilter.value) {
  239. if (filterBy.value === 'online') {
  240. rows = rows.filter((r) => r.enable && isOnline(r.email));
  241. } else if (filterBy.value) {
  242. rows = rows.filter((r) => clientBucket(r) === filterBy.value);
  243. }
  244. } else if (!ObjectUtil.isEmpty(searchKey.value)) {
  245. rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey.value));
  246. }
  247. if (protocolFilter.value) {
  248. rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter.value));
  249. }
  250. return rows;
  251. });
  252. const summary = computed(() => {
  253. const rows = clients.value || [];
  254. const deactive = [];
  255. const depleted = [];
  256. const expiring = [];
  257. const online = [];
  258. let active = 0;
  259. for (const row of rows) {
  260. const bucket = clientBucket(row);
  261. if (bucket === 'deactive') deactive.push(row.email);
  262. else if (bucket === 'depleted') depleted.push(row.email);
  263. else if (bucket === 'expiring') expiring.push(row.email);
  264. else if (bucket === 'active') active++;
  265. if (row.enable && isOnline(row.email)) online.push(row.email);
  266. }
  267. return { total: rows.length, active, deactive, depleted, expiring, online };
  268. });
  269. function onAdd() {
  270. formMode.value = 'add';
  271. editingClient.value = null;
  272. editingAttachedIds.value = [];
  273. formOpen.value = true;
  274. }
  275. function onEdit(row) {
  276. formMode.value = 'edit';
  277. editingClient.value = { ...row };
  278. editingAttachedIds.value = Array.isArray(row.inboundIds) ? [...row.inboundIds] : [];
  279. formOpen.value = true;
  280. }
  281. function onDelete(row) {
  282. Modal.confirm({
  283. title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
  284. content: t('pages.clients.deleteConfirmContent'),
  285. okText: t('delete'),
  286. okType: 'danger',
  287. cancelText: t('cancel'),
  288. onOk: async () => {
  289. const msg = await remove(row.email);
  290. if (msg?.success) message.success(t('pages.clients.toasts.deleted'));
  291. },
  292. });
  293. }
  294. function onResetTraffic(row) {
  295. if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
  296. message.warning(t('pages.clients.resetNotPossible'));
  297. return;
  298. }
  299. Modal.confirm({
  300. title: `${t('pages.inbounds.resetTraffic')} — ${row.email}`,
  301. content: t('pages.inbounds.resetTrafficContent'),
  302. okText: t('reset'),
  303. cancelText: t('cancel'),
  304. onOk: async () => {
  305. const msg = await resetTraffic(row);
  306. if (msg?.success) message.success(t('pages.clients.toasts.trafficReset'));
  307. },
  308. });
  309. }
  310. function onShowInfo(row) {
  311. infoClient.value = row;
  312. infoOpen.value = true;
  313. }
  314. function onShowQr(row) {
  315. qrClient.value = row;
  316. qrOpen.value = true;
  317. }
  318. function onResetAllTraffics() {
  319. Modal.confirm({
  320. title: t('pages.clients.resetAllTrafficsTitle'),
  321. content: t('pages.clients.resetAllTrafficsContent'),
  322. okText: t('reset'),
  323. okType: 'danger',
  324. cancelText: t('cancel'),
  325. onOk: async () => {
  326. const msg = await resetAllTraffics();
  327. if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset'));
  328. },
  329. });
  330. }
  331. async function onSave(payload, meta) {
  332. if (!meta?.isEdit) {
  333. return create(payload);
  334. }
  335. const updateMsg = await update(meta.email, payload);
  336. if (!updateMsg?.success) return updateMsg;
  337. if (Array.isArray(meta.attach) && meta.attach.length > 0) {
  338. const r = await attach(meta.email, meta.attach);
  339. if (!r?.success) return r;
  340. }
  341. if (Array.isArray(meta.detach) && meta.detach.length > 0) {
  342. const r = await detach(meta.email, meta.detach);
  343. if (!r?.success) return r;
  344. }
  345. return updateMsg;
  346. }
  347. function trafficLabel(row) {
  348. const t0 = row.traffic;
  349. if (!t0) return '-';
  350. const used = (t0.up || 0) + (t0.down || 0);
  351. const total = row.totalGB || 0;
  352. if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
  353. return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
  354. }
  355. function remainingLabel(row) {
  356. const total = row.totalGB || 0;
  357. if (total <= 0) return '∞';
  358. const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
  359. const r = total - used;
  360. return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
  361. }
  362. function remainingColor(row) {
  363. const total = row.totalGB || 0;
  364. if (total <= 0) return 'purple';
  365. const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
  366. const ratio = used / total;
  367. if (ratio >= 1) return 'red';
  368. if (ratio >= 0.85) return 'orange';
  369. return 'green';
  370. }
  371. function expiryLabel(row) {
  372. if (!row.expiryTime) return '∞';
  373. if (row.expiryTime < 0) {
  374. const days = Math.round(row.expiryTime / -86400000);
  375. return `${t('pages.clients.delayedStart')}: ${days}d`;
  376. }
  377. return IntlUtil.formatDate(row.expiryTime);
  378. }
  379. function expiryRelative(row) {
  380. if (!row.expiryTime) return '';
  381. if (row.expiryTime < 0) {
  382. const days = Math.round(row.expiryTime / -86400000);
  383. return `${days}d`;
  384. }
  385. return IntlUtil.formatRelativeTime(row.expiryTime);
  386. }
  387. function expiryColor(row) {
  388. if (!row.expiryTime) return 'purple';
  389. if (row.expiryTime < 0) return 'blue';
  390. const now = Date.now();
  391. if (row.expiryTime <= now) return 'red';
  392. if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
  393. return 'green';
  394. }
  395. const sortState = ref({ column: null, order: null });
  396. const paginationState = ref({ current: 1, pageSize: 20 });
  397. watch(pageSize, (next) => {
  398. if (next > 0) paginationState.value.pageSize = next;
  399. }, { immediate: true });
  400. function sortableCol(col, key) {
  401. return {
  402. ...col,
  403. sorter: true,
  404. showSorterTooltip: false,
  405. sortOrder: sortState.value.column === key ? sortState.value.order : null,
  406. sortDirections: ['ascend', 'descend'],
  407. };
  408. }
  409. const sortFns = {
  410. enable: (a, b) => Number(a.enable) - Number(b.enable),
  411. email: (a, b) => (a.email || '').localeCompare(b.email || ''),
  412. inboundIds: (a, b) => (a.inboundIds?.length || 0) - (b.inboundIds?.length || 0),
  413. traffic: (a, b) => {
  414. const ua = (a.traffic?.up || 0) + (a.traffic?.down || 0);
  415. const ub = (b.traffic?.up || 0) + (b.traffic?.down || 0);
  416. return ua - ub;
  417. },
  418. remaining: (a, b) => {
  419. const ra = a.totalGB > 0 ? a.totalGB - ((a.traffic?.up || 0) + (a.traffic?.down || 0)) : Infinity;
  420. const rb = b.totalGB > 0 ? b.totalGB - ((b.traffic?.up || 0) + (b.traffic?.down || 0)) : Infinity;
  421. return ra - rb;
  422. },
  423. expiryTime: (a, b) => {
  424. const ea = a.expiryTime > 0 ? a.expiryTime : Infinity;
  425. const eb = b.expiryTime > 0 ? b.expiryTime : Infinity;
  426. return ea - eb;
  427. },
  428. };
  429. const sortedClients = computed(() => {
  430. const { column, order } = sortState.value;
  431. const rows = filteredClients.value;
  432. if (!column || !order) return rows;
  433. const fn = sortFns[column];
  434. if (!fn) return rows;
  435. const sorted = [...rows].sort(fn);
  436. return order === 'descend' ? sorted.reverse() : sorted;
  437. });
  438. function onTableChange(pag, _filters, sorter) {
  439. if (pag) {
  440. paginationState.value = {
  441. current: pag.current || 1,
  442. pageSize: pag.pageSize || paginationState.value.pageSize,
  443. };
  444. }
  445. sortState.value = {
  446. column: sorter?.columnKey || sorter?.field || null,
  447. order: sorter?.order || null,
  448. };
  449. }
  450. const tablePagination = computed(() => ({
  451. current: paginationState.value.current,
  452. pageSize: paginationState.value.pageSize,
  453. total: sortedClients.value.length,
  454. showSizeChanger: sortedClients.value.length > 10,
  455. pageSizeOptions: ['10', '20', '50', '100'],
  456. hideOnSinglePage: sortedClients.value.length <= paginationState.value.pageSize,
  457. }));
  458. const columns = computed(() => [
  459. { title: t('pages.clients.actions'), key: 'actions', width: 200 },
  460. sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'),
  461. { title: t('pages.clients.online'), key: 'online', width: 90 },
  462. sortableCol({ title: t('pages.clients.client'), key: 'email' }, 'email'),
  463. sortableCol({ title: t('pages.clients.attachedInbounds'), key: 'inboundIds' }, 'inboundIds'),
  464. sortableCol({ title: t('pages.clients.traffic'), key: 'traffic' }, 'traffic'),
  465. sortableCol({ title: t('pages.clients.remaining'), key: 'remaining', width: 130 }, 'remaining'),
  466. sortableCol({ title: t('pages.clients.duration'), key: 'expiryTime' }, 'expiryTime'),
  467. ]);
  468. </script>
  469. <template>
  470. <a-config-provider :theme="antdThemeConfig">
  471. <a-layout class="clients-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
  472. <AppSidebar :base-path="basePath" :request-uri="requestUri" />
  473. <a-layout class="content-shell">
  474. <a-layout-content id="content-layout" class="content-area">
  475. <a-spin :spinning="!fetched" :delay="200" :tip="t('loading')" size="large">
  476. <div v-if="!fetched" class="loading-spacer" />
  477. <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
  478. <a-col :span="24">
  479. <a-card size="small" hoverable class="summary-card">
  480. <a-row :gutter="[16, 12]">
  481. <a-col :xs="12" :sm="8" :md="4">
  482. <CustomStatistic :title="t('clients')" :value="String(summary.total)">
  483. <template #prefix>
  484. <TeamOutlined />
  485. </template>
  486. </CustomStatistic>
  487. </a-col>
  488. <a-col :xs="12" :sm="8" :md="4">
  489. <a-popover :title="t('online')" :open="summary.online.length ? undefined : false">
  490. <template #content>
  491. <div class="client-email-list">
  492. <div v-for="email in summary.online" :key="email">{{ email }}</div>
  493. </div>
  494. </template>
  495. <CustomStatistic :title="t('online')" :value="String(summary.online.length)">
  496. <template #prefix>
  497. <span class="dot dot-blue" />
  498. </template>
  499. </CustomStatistic>
  500. </a-popover>
  501. </a-col>
  502. <a-col :xs="12" :sm="8" :md="4">
  503. <a-popover :title="t('depleted')" :open="summary.depleted.length ? undefined : false">
  504. <template #content>
  505. <div class="client-email-list">
  506. <div v-for="email in summary.depleted" :key="email">{{ email }}</div>
  507. </div>
  508. </template>
  509. <CustomStatistic :title="t('depleted')" :value="String(summary.depleted.length)">
  510. <template #prefix>
  511. <span class="dot dot-red" />
  512. </template>
  513. </CustomStatistic>
  514. </a-popover>
  515. </a-col>
  516. <a-col :xs="12" :sm="8" :md="4">
  517. <a-popover :title="t('depletingSoon')" :open="summary.expiring.length ? undefined : false">
  518. <template #content>
  519. <div class="client-email-list">
  520. <div v-for="email in summary.expiring" :key="email">{{ email }}</div>
  521. </div>
  522. </template>
  523. <CustomStatistic :title="t('depletingSoon')" :value="String(summary.expiring.length)">
  524. <template #prefix>
  525. <span class="dot dot-orange" />
  526. </template>
  527. </CustomStatistic>
  528. </a-popover>
  529. </a-col>
  530. <a-col :xs="12" :sm="8" :md="4">
  531. <a-popover :title="t('disabled')" :open="summary.deactive.length ? undefined : false">
  532. <template #content>
  533. <div class="client-email-list">
  534. <div v-for="email in summary.deactive" :key="email">{{ email }}</div>
  535. </div>
  536. </template>
  537. <CustomStatistic :title="t('disabled')" :value="String(summary.deactive.length)">
  538. <template #prefix>
  539. <span class="dot dot-gray" />
  540. </template>
  541. </CustomStatistic>
  542. </a-popover>
  543. </a-col>
  544. <a-col :xs="12" :sm="8" :md="4">
  545. <CustomStatistic :title="t('subscription.active')" :value="String(summary.active)">
  546. <template #prefix>
  547. <span class="dot dot-green" />
  548. </template>
  549. </CustomStatistic>
  550. </a-col>
  551. </a-row>
  552. </a-card>
  553. </a-col>
  554. <a-col :span="24">
  555. <a-card size="small">
  556. <template #title>
  557. <div class="card-toolbar">
  558. <a-button type="primary" size="small" @click="onAdd">
  559. <template #icon>
  560. <PlusOutlined />
  561. </template>
  562. <template v-if="!isMobile">{{ t('pages.clients.addClients') }}</template>
  563. </a-button>
  564. <a-button size="small" @click="onBulkAdd">
  565. <template #icon>
  566. <UsergroupAddOutlined />
  567. </template>
  568. <template v-if="!isMobile">{{ t('pages.clients.bulk') }}</template>
  569. </a-button>
  570. <a-button v-if="selectedRowKeys.length > 0" danger size="small" @click="onBulkDelete">
  571. <template #icon>
  572. <DeleteOutlined />
  573. </template>
  574. {{ t('pages.clients.deleteSelected', { count: selectedRowKeys.length }) }}
  575. </a-button>
  576. <a-button size="small" @click="onResetAllTraffics">
  577. <template #icon>
  578. <RetweetOutlined />
  579. </template>
  580. <template v-if="!isMobile">{{ t('pages.clients.resetAllTraffics') }}</template>
  581. </a-button>
  582. <a-button size="small" danger @click="onDelDepleted">
  583. <template #icon>
  584. <RestOutlined />
  585. </template>
  586. <template v-if="!isMobile">{{ t('pages.clients.delDepleted') }}</template>
  587. </a-button>
  588. </div>
  589. </template>
  590. <div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
  591. <a-switch v-model:checked="enableFilter" @change="onToggleFilter">
  592. <template #checkedChildren>
  593. <SearchOutlined />
  594. </template>
  595. <template #unCheckedChildren>
  596. <FilterOutlined />
  597. </template>
  598. </a-switch>
  599. <a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
  600. :size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
  601. <a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
  602. :size="isMobile ? 'small' : 'middle'">
  603. <a-radio-button value="">{{ t('none') }}</a-radio-button>
  604. <a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
  605. <a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
  606. <a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
  607. <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
  608. <a-radio-button value="online">{{ t('online') }}</a-radio-button>
  609. </a-radio-group>
  610. <a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
  611. :size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
  612. <a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
  613. {{ protocol }}
  614. </a-select-option>
  615. </a-select>
  616. </div>
  617. <a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading"
  618. row-key="email" :row-selection="rowSelection" :pagination="tablePagination" size="small"
  619. @change="onTableChange">
  620. <template #bodyCell="{ column, record }">
  621. <template v-if="column.key === 'email'">
  622. <div class="email-cell">
  623. <span class="email">{{ record.email }}</span>
  624. <span v-if="record.subId" class="sub" :title="record.subId">{{ record.subId }}</span>
  625. </div>
  626. </template>
  627. <template v-else-if="column.key === 'online'">
  628. <a-tag v-if="clientBucket(record) === 'depleted'" color="red">
  629. {{ t('depleted') }}
  630. </a-tag>
  631. <a-tag v-else-if="record.enable && isOnline(record.email)" color="green">
  632. {{ t('pages.clients.online') }}
  633. </a-tag>
  634. <a-tag v-else-if="!record.enable">{{ t('disabled') }}</a-tag>
  635. <a-tag v-else-if="clientBucket(record) === 'expiring'" color="orange">
  636. {{ t('depletingSoon') }}
  637. </a-tag>
  638. <a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
  639. </template>
  640. <template v-else-if="column.key === 'inboundIds'">
  641. <a-tag v-for="id in record.inboundIds" :key="id" color="blue" style="margin: 2px">
  642. {{ inboundLabel(id) }}
  643. </a-tag>
  644. <span v-if="!record.inboundIds || record.inboundIds.length === 0"
  645. style="color: rgba(0,0,0,0.45)">—</span>
  646. </template>
  647. <template v-else-if="column.key === 'traffic'">
  648. {{ trafficLabel(record) }}
  649. </template>
  650. <template v-else-if="column.key === 'remaining'">
  651. <a-tag :color="remainingColor(record)">{{ remainingLabel(record) }}</a-tag>
  652. </template>
  653. <template v-else-if="column.key === 'expiryTime'">
  654. <a-tooltip :title="expiryLabel(record)">
  655. <a-tag :color="expiryColor(record)">
  656. {{ record.expiryTime ? expiryRelative(record) : '∞' }}
  657. </a-tag>
  658. </a-tooltip>
  659. </template>
  660. <template v-else-if="column.key === 'enable'">
  661. <a-switch :checked="record.enable" size="small" :loading="togglingEmail === record.email"
  662. @change="(next) => onToggleEnable(record, next)" />
  663. </template>
  664. <template v-else-if="column.key === 'actions'">
  665. <a-space :size="4">
  666. <a-tooltip :title="t('pages.clients.qrCode')">
  667. <a-button size="small" type="text" @click="onShowQr(record)">
  668. <QrcodeOutlined />
  669. </a-button>
  670. </a-tooltip>
  671. <a-tooltip :title="t('pages.clients.moreInformation')">
  672. <a-button size="small" type="text" @click="onShowInfo(record)">
  673. <InfoCircleOutlined />
  674. </a-button>
  675. </a-tooltip>
  676. <a-tooltip :title="t('pages.inbounds.resetTraffic')">
  677. <a-button size="small" type="text" @click="onResetTraffic(record)">
  678. <RetweetOutlined />
  679. </a-button>
  680. </a-tooltip>
  681. <a-tooltip :title="t('edit')">
  682. <a-button size="small" type="text" @click="onEdit(record)">
  683. <EditOutlined />
  684. </a-button>
  685. </a-tooltip>
  686. <a-tooltip :title="t('delete')">
  687. <a-button size="small" type="text" danger @click="onDelete(record)">
  688. <DeleteOutlined />
  689. </a-button>
  690. </a-tooltip>
  691. </a-space>
  692. </template>
  693. </template>
  694. <template #emptyText>
  695. <div class="clients-empty">
  696. <UserOutlined style="font-size: 32px; margin-bottom: 8px" />
  697. <div>{{ t('pages.clients.empty') }}</div>
  698. </div>
  699. </template>
  700. </a-table>
  701. <a-spin v-else :spinning="loading">
  702. <div class="client-cards">
  703. <div v-if="filteredClients.length > 0" class="card-bulk-bar">
  704. <a-checkbox :checked="allSelected" :indeterminate="someSelected"
  705. @change="(e) => selectAll(e.target.checked)">
  706. {{ t('pages.clients.selectAll') }}
  707. </a-checkbox>
  708. <span v-if="selectedRowKeys.length > 0" class="bulk-count">
  709. {{ selectedRowKeys.length }}
  710. </span>
  711. </div>
  712. <div v-if="filteredClients.length === 0" class="card-empty">
  713. <UserOutlined style="font-size: 28px; opacity: 0.5" />
  714. <div>{{ t('pages.clients.empty') }}</div>
  715. </div>
  716. <div v-for="row in filteredClients" :key="row.email" class="client-card"
  717. :class="{ 'is-selected': isSelected(row.email) }">
  718. <div class="card-head">
  719. <a-checkbox :checked="isSelected(row.email)"
  720. @change="(e) => toggleSelect(row.email, e.target.checked)" />
  721. <a-badge :color="bucketTagColor(clientBucket(row))" />
  722. <span class="tag-name">{{ row.email }}</span>
  723. <a-tag v-if="clientBucket(row) === 'depleted'" color="red" class="status-tag">
  724. {{ t('depleted') }}
  725. </a-tag>
  726. <a-tag v-else-if="clientBucket(row) === 'expiring'" color="orange" class="status-tag">
  727. {{ t('depletingSoon') }}
  728. </a-tag>
  729. <div class="card-actions" @click.stop>
  730. <a-tooltip :title="t('pages.clients.moreInformation')">
  731. <InfoCircleOutlined class="row-action-trigger" @click="onShowInfo(row)" />
  732. </a-tooltip>
  733. <a-switch :checked="row.enable" size="small" :loading="togglingEmail === row.email"
  734. @change="(next) => onToggleEnable(row, next)" />
  735. <a-dropdown :trigger="['click']" placement="bottomRight">
  736. <MoreOutlined class="row-action-trigger" @click.prevent />
  737. <template #overlay>
  738. <a-menu>
  739. <a-menu-item key="qr" @click="onShowQr(row)">
  740. <QrcodeOutlined /> {{ t('pages.clients.qrCode') }}
  741. </a-menu-item>
  742. <a-menu-item key="reset" @click="onResetTraffic(row)">
  743. <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
  744. </a-menu-item>
  745. <a-menu-item key="edit" @click="onEdit(row)">
  746. <EditOutlined /> {{ t('edit') }}
  747. </a-menu-item>
  748. <a-menu-item key="delete" class="danger-item" @click="onDelete(row)">
  749. <DeleteOutlined /> {{ t('delete') }}
  750. </a-menu-item>
  751. </a-menu>
  752. </template>
  753. </a-dropdown>
  754. </div>
  755. </div>
  756. </div>
  757. </div>
  758. </a-spin>
  759. </a-card>
  760. </a-col>
  761. </a-row>
  762. </a-spin>
  763. </a-layout-content>
  764. </a-layout>
  765. <ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
  766. :attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
  767. :tg-bot-enable="tgBotEnable" :save="onSave" />
  768. <ClientInfoModal v-model:open="infoOpen" :client="infoClient" :inbounds-by-id="inboundsById"
  769. :is-online="infoClient ? isOnline(infoClient.email) : false" :sub-settings="subSettings" />
  770. <ClientQrModal v-model:open="qrOpen" :client="qrClient" :sub-settings="subSettings" />
  771. <ClientBulkAddModal v-model:open="bulkAddOpen" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
  772. @saved="onBulkAddSaved" />
  773. </a-layout>
  774. </a-config-provider>
  775. </template>
  776. <style scoped>
  777. .clients-page {
  778. --bg-page: #e6e8ec;
  779. --bg-card: #ffffff;
  780. min-height: 100vh;
  781. background: var(--bg-page);
  782. }
  783. .clients-page :deep(.ant-pagination-options-size-changer),
  784. .clients-page :deep(.ant-pagination-options-size-changer .ant-select-selector) {
  785. min-width: 100px !important;
  786. }
  787. .clients-page.is-dark {
  788. --bg-page: #1e1e1e;
  789. --bg-card: #252526;
  790. }
  791. .clients-page.is-dark.is-ultra {
  792. --bg-page: #050505;
  793. --bg-card: #0c0e12;
  794. }
  795. .clients-page :deep(.ant-layout),
  796. .clients-page :deep(.ant-layout-content) {
  797. background: transparent;
  798. }
  799. .content-shell {
  800. background: transparent;
  801. }
  802. .filter-bar {
  803. display: flex;
  804. flex-wrap: wrap;
  805. align-items: center;
  806. gap: 8px;
  807. margin-bottom: 12px;
  808. }
  809. .filter-bar.mobile {
  810. gap: 6px;
  811. margin-bottom: 8px;
  812. }
  813. .filter-bar.mobile>* {
  814. flex: 0 0 auto;
  815. }
  816. .content-area {
  817. padding: 24px;
  818. }
  819. @media (max-width: 768px) {
  820. .content-area {
  821. padding: 8px;
  822. }
  823. }
  824. .loading-spacer {
  825. min-height: calc(100vh - 120px);
  826. }
  827. .summary-card {
  828. padding: 16px;
  829. }
  830. @media (max-width: 768px) {
  831. .summary-card {
  832. padding: 8px;
  833. }
  834. }
  835. .dot {
  836. display: inline-block;
  837. width: 8px;
  838. height: 8px;
  839. border-radius: 50%;
  840. margin-right: 4px;
  841. vertical-align: middle;
  842. }
  843. .dot-green {
  844. background: #52c41a;
  845. }
  846. .dot-blue {
  847. background: #1677ff;
  848. }
  849. .dot-red {
  850. background: #ff4d4f;
  851. }
  852. .dot-orange {
  853. background: #fa8c16;
  854. }
  855. .dot-gray {
  856. background: rgba(128, 128, 128, 0.6);
  857. }
  858. .status-tag {
  859. margin: 0 0 0 4px;
  860. font-size: 11px;
  861. padding: 0 6px;
  862. line-height: 18px;
  863. }
  864. .card-toolbar {
  865. display: flex;
  866. align-items: center;
  867. gap: 8px;
  868. flex-wrap: wrap;
  869. }
  870. .card-title {
  871. font-weight: 600;
  872. margin-right: 4px;
  873. }
  874. .email-cell {
  875. display: flex;
  876. flex-direction: column;
  877. }
  878. .email {
  879. font-weight: 500;
  880. }
  881. .sub {
  882. font-size: 11px;
  883. opacity: 0.55;
  884. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  885. white-space: nowrap;
  886. overflow: hidden;
  887. text-overflow: ellipsis;
  888. max-width: 220px;
  889. }
  890. .client-cards {
  891. display: flex;
  892. flex-direction: column;
  893. gap: 10px;
  894. margin-top: 4px;
  895. }
  896. .card-bulk-bar {
  897. display: flex;
  898. align-items: center;
  899. gap: 8px;
  900. padding: 4px 4px 8px;
  901. }
  902. .bulk-count {
  903. font-size: 12px;
  904. background: rgba(22, 119, 255, 0.12);
  905. color: var(--ant-color-primary, #1677ff);
  906. padding: 1px 8px;
  907. border-radius: 10px;
  908. }
  909. .client-card {
  910. border: 1px solid rgba(128, 128, 128, 0.2);
  911. border-radius: 10px;
  912. padding: 10px 12px;
  913. background: rgba(255, 255, 255, 0.02);
  914. }
  915. .client-card.is-selected {
  916. border-color: var(--ant-color-primary, #1677ff);
  917. background: rgba(22, 119, 255, 0.06);
  918. }
  919. :global(body.dark) .client-card {
  920. background: rgba(255, 255, 255, 0.03);
  921. border-color: rgba(255, 255, 255, 0.1);
  922. }
  923. .card-head {
  924. display: flex;
  925. align-items: center;
  926. gap: 8px;
  927. user-select: none;
  928. }
  929. .card-head .tag-name {
  930. font-weight: 600;
  931. flex: 1;
  932. min-width: 0;
  933. overflow: hidden;
  934. text-overflow: ellipsis;
  935. white-space: nowrap;
  936. }
  937. .card-actions {
  938. display: flex;
  939. align-items: center;
  940. gap: 10px;
  941. flex-shrink: 0;
  942. }
  943. .row-action-trigger {
  944. font-size: 18px;
  945. cursor: pointer;
  946. opacity: 0.75;
  947. transition: opacity 120ms ease;
  948. }
  949. .row-action-trigger:hover {
  950. opacity: 1;
  951. }
  952. .card-empty {
  953. text-align: center;
  954. padding: 40px 16px;
  955. opacity: 0.55;
  956. display: flex;
  957. flex-direction: column;
  958. align-items: center;
  959. gap: 8px;
  960. }
  961. .clients-empty {
  962. padding: 32px 0;
  963. text-align: center;
  964. opacity: 0.55;
  965. }
  966. .danger-item {
  967. color: #ff4d4f;
  968. }
  969. </style>
  970. <style>
  971. .client-email-list {
  972. max-height: 280px;
  973. min-width: 160px;
  974. overflow-y: auto;
  975. padding-right: 4px;
  976. }
  977. .client-email-list>div {
  978. padding: 2px 0;
  979. font-size: 12px;
  980. white-space: nowrap;
  981. }
  982. .ant-select-dropdown:has(.ant-select-item-option[title$="/ page"]) {
  983. min-width: 110px !important;
  984. }
  985. .ant-select-dropdown:has(.ant-select-item-option[title$="/ page"]) .ant-select-item-option-content {
  986. white-space: nowrap;
  987. }
  988. </style>