ClientsPage.vue 36 KB

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