ClientsPage.tsx 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315
  1. import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Badge,
  5. Button,
  6. Card,
  7. Checkbox,
  8. Col,
  9. ConfigProvider,
  10. Dropdown,
  11. Input,
  12. Layout,
  13. Modal,
  14. Pagination,
  15. Popover,
  16. Row,
  17. Select,
  18. Space,
  19. Spin,
  20. Statistic,
  21. Switch,
  22. Table,
  23. Tag,
  24. Tooltip,
  25. message,
  26. } from 'antd';
  27. import type { ColumnsType, TableProps } from 'antd/es/table';
  28. import {
  29. ClockCircleOutlined,
  30. DeleteOutlined,
  31. EditOutlined,
  32. FilterOutlined,
  33. InfoCircleOutlined,
  34. LinkOutlined,
  35. MoreOutlined,
  36. PlusOutlined,
  37. QrcodeOutlined,
  38. RestOutlined,
  39. RetweetOutlined,
  40. SearchOutlined,
  41. SortAscendingOutlined,
  42. TagsOutlined,
  43. TeamOutlined,
  44. UsergroupAddOutlined,
  45. UsergroupDeleteOutlined,
  46. } from '@ant-design/icons';
  47. import { useTheme } from '@/hooks/useTheme';
  48. import { useMediaQuery } from '@/hooks/useMediaQuery';
  49. import { useWebSocket } from '@/hooks/useWebSocket';
  50. import { useClients } from '@/hooks/useClients';
  51. import { useDatepicker } from '@/hooks/useDatepicker';
  52. import type { ClientRecord, InboundOption } from '@/hooks/useClients';
  53. import AppSidebar from '@/components/AppSidebar';
  54. import { IntlUtil, SizeFormatter } from '@/utils';
  55. import { setMessageInstance } from '@/utils/messageBus';
  56. import LazyMount from '@/components/LazyMount';
  57. const ClientFormModal = lazy(() => import('./ClientFormModal'));
  58. const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
  59. const ClientQrModal = lazy(() => import('./ClientQrModal'));
  60. const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
  61. const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
  62. const FilterDrawer = lazy(() => import('./FilterDrawer'));
  63. const SubLinksModal = lazy(() => import('./SubLinksModal'));
  64. const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
  65. const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
  66. const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
  67. import { emptyFilters, activeFilterCount } from './filters';
  68. import type { ClientFilters } from './filters';
  69. import './ClientsPage.css';
  70. const FILTER_STATE_KEY = 'clientsFilterState';
  71. function UngroupIcon() {
  72. return (
  73. <span
  74. style={{
  75. position: 'relative',
  76. display: 'inline-flex',
  77. alignItems: 'center',
  78. justifyContent: 'center',
  79. width: '1em',
  80. height: '1em',
  81. }}
  82. >
  83. <TagsOutlined />
  84. <span
  85. aria-hidden="true"
  86. style={{
  87. position: 'absolute',
  88. inset: 0,
  89. display: 'flex',
  90. alignItems: 'center',
  91. justifyContent: 'center',
  92. pointerEvents: 'none',
  93. }}
  94. >
  95. <span
  96. style={{
  97. display: 'block',
  98. width: '125%',
  99. height: '1.5px',
  100. background: 'currentColor',
  101. transform: 'rotate(-45deg)',
  102. borderRadius: '1px',
  103. }}
  104. />
  105. </span>
  106. </span>
  107. );
  108. }
  109. type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
  110. interface PersistedFilterState {
  111. searchKey: string;
  112. filters: ClientFilters;
  113. }
  114. const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
  115. vless: 'blue',
  116. vmess: 'geekblue',
  117. trojan: 'volcano',
  118. shadowsocks: 'magenta',
  119. hysteria: 'cyan',
  120. hysteria2: 'green',
  121. wireguard: 'gold',
  122. http: 'purple',
  123. mixed: 'lime',
  124. tunnel: 'orange',
  125. };
  126. const INBOUND_CHIP_LIMIT = 1;
  127. function readFilterState(): PersistedFilterState {
  128. try {
  129. const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
  130. const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
  131. return {
  132. searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '',
  133. filters: {
  134. ...emptyFilters(),
  135. ...fromRaw,
  136. buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
  137. protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
  138. inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
  139. groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
  140. },
  141. };
  142. } catch {
  143. return { searchKey: '', filters: emptyFilters() };
  144. }
  145. }
  146. function gbToBytes(gb: number | undefined): number {
  147. if (!gb || gb <= 0) return 0;
  148. return Math.round(gb * 1024 * 1024 * 1024);
  149. }
  150. const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
  151. { value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' },
  152. { value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' },
  153. { value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' },
  154. { value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' },
  155. { value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' },
  156. { value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' },
  157. { value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' },
  158. { value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' },
  159. { value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' },
  160. ];
  161. const DEFAULT_SORT = SORT_OPTIONS[0];
  162. function sortValueFor(column: string | null, order: 'ascend' | 'descend' | null): string {
  163. if (!column || !order) return DEFAULT_SORT.value;
  164. return `${column}:${order}`;
  165. }
  166. export default function ClientsPage() {
  167. const { t } = useTranslation();
  168. const { isDark, isUltra, antdThemeConfig } = useTheme();
  169. const { datepicker } = useDatepicker();
  170. const { isMobile } = useMediaQuery();
  171. const [modal, modalContextHolder] = Modal.useModal();
  172. const [messageApi, messageContextHolder] = message.useMessage();
  173. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  174. const {
  175. clients, filtered,
  176. summary: serverSummary,
  177. allGroups,
  178. setQuery,
  179. inbounds, onlines, loading, fetched, subSettings,
  180. ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
  181. create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
  182. resetTraffic, resetAllTraffics, delDepleted, setEnable,
  183. applyTrafficEvent, applyClientStatsEvent,
  184. hydrate,
  185. } = useClients();
  186. useWebSocket({
  187. traffic: applyTrafficEvent,
  188. client_stats: applyClientStatsEvent,
  189. });
  190. const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
  191. const [formOpen, setFormOpen] = useState(false);
  192. const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
  193. const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
  194. const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
  195. const [infoOpen, setInfoOpen] = useState(false);
  196. const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
  197. const [qrOpen, setQrOpen] = useState(false);
  198. const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
  199. const [bulkAddOpen, setBulkAddOpen] = useState(false);
  200. const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
  201. const [subLinksOpen, setSubLinksOpen] = useState(false);
  202. const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
  203. const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
  204. const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
  205. const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
  206. const initial = readFilterState();
  207. const [searchKey, setSearchKey] = useState(initial.searchKey);
  208. const [filters, setFilters] = useState<ClientFilters>(initial.filters);
  209. const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
  210. const [sortColumn, setSortColumn] = useState<string | null>(DEFAULT_SORT.column);
  211. const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.order);
  212. const [currentPage, setCurrentPage] = useState(1);
  213. const [tablePageSize, setTablePageSize] = useState(25);
  214. // debouncedSearch lags behind the input so we don't spam the server on every
  215. // keystroke; the search box still feels instant locally.
  216. const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
  217. useEffect(() => {
  218. localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters }));
  219. }, [searchKey, filters]);
  220. useEffect(() => {
  221. const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
  222. return () => window.clearTimeout(handle);
  223. }, [searchKey]);
  224. useEffect(() => {
  225. // Reset to page 1 whenever a filter or sort changes — otherwise an empty
  226. // result set on a high page number leaves the user staring at "no clients".
  227. setCurrentPage(1);
  228. }, [debouncedSearch, filters, sortColumn, sortOrder]);
  229. useEffect(() => {
  230. setQuery({
  231. page: currentPage,
  232. pageSize: tablePageSize,
  233. search: debouncedSearch,
  234. filter: filters.buckets.join(','),
  235. protocol: filters.protocols.join(','),
  236. inbound: filters.inboundIds.join(','),
  237. expiryFrom: filters.expiryFrom,
  238. expiryTo: filters.expiryTo,
  239. usageFrom: gbToBytes(filters.usageFromGB),
  240. usageTo: gbToBytes(filters.usageToGB),
  241. autoRenew: filters.autoRenew || undefined,
  242. hasTgId: filters.hasTgId || undefined,
  243. hasComment: filters.hasComment || undefined,
  244. group: filters.groups.join(',') || undefined,
  245. sort: sortColumn || undefined,
  246. order: sortOrder || undefined,
  247. });
  248. }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
  249. const activeCount = activeFilterCount(filters);
  250. useEffect(() => {
  251. if (pageSize > 0) {
  252. setTablePageSize(pageSize);
  253. }
  254. }, [pageSize]);
  255. const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
  256. const inboundsById = useMemo(() => {
  257. const out: Record<number, InboundOption> = {};
  258. for (const ib of inbounds) out[ib.id] = ib;
  259. return out;
  260. }, [inbounds]);
  261. const protocolOptions = useMemo(() => {
  262. const values = new Set<string>((inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x));
  263. return [...values].sort();
  264. }, [inbounds]);
  265. const groupOptions = useMemo(() => {
  266. const values = new Set<string>(allGroups);
  267. for (const g of filters.groups) values.add(g);
  268. return [...values].sort((a, b) => a.localeCompare(b));
  269. }, [allGroups, filters.groups]);
  270. const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
  271. function inboundLabel(id: number) {
  272. const ib = inboundsById[id];
  273. return ib?.tag ?? '';
  274. }
  275. const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
  276. if (!row) return null;
  277. const traffic = row.traffic || {};
  278. const used = (traffic.up || 0) + (traffic.down || 0);
  279. const total = row.totalGB || 0;
  280. const now = Date.now();
  281. const expired = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) <= now;
  282. const exhausted = total > 0 && used >= total;
  283. if (expired || exhausted) return 'depleted';
  284. if (!row.enable) return 'deactive';
  285. const nearExpiry = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) - now < (expireDiff || 0);
  286. const nearLimit = total > 0 && total - used < (trafficDiff || 0);
  287. if (nearExpiry || nearLimit) return 'expiring';
  288. return 'active';
  289. }, [expireDiff, trafficDiff]);
  290. function bucketBadgeStatus(bucket: Bucket | null): 'success' | 'warning' | 'error' | 'default' {
  291. switch (bucket) {
  292. case 'depleted': return 'error';
  293. case 'expiring': return 'warning';
  294. case 'active': return 'success';
  295. default: return 'default';
  296. }
  297. }
  298. // The list page renders rows the server already sorted, filtered, and
  299. // paginated. Local filtering is gone — keep the variable name so the rest
  300. // of the file (table dataSource, mobile cards, select-all) doesn't need
  301. // a rename.
  302. const filteredClients = clients;
  303. // Server-computed counts that stay stable as the user paginates/filters.
  304. const summary = serverSummary;
  305. // Sort is server-side now; the page already arrives in the requested
  306. // order, so we just hand it through.
  307. const sortedClients = filteredClients;
  308. function trafficLabel(row: ClientRecord) {
  309. const t0 = row.traffic;
  310. if (!t0) return '-';
  311. const used = (t0.up || 0) + (t0.down || 0);
  312. const total = row.totalGB || 0;
  313. if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
  314. return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
  315. }
  316. function remainingLabel(row: ClientRecord) {
  317. const total = row.totalGB || 0;
  318. if (total <= 0) return '∞';
  319. const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
  320. const r = total - used;
  321. return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
  322. }
  323. function remainingColor(row: ClientRecord): string {
  324. const total = row.totalGB || 0;
  325. if (total <= 0) return 'purple';
  326. const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
  327. const ratio = used / total;
  328. if (ratio >= 1) return 'red';
  329. if (ratio >= 0.85) return 'orange';
  330. return 'green';
  331. }
  332. function expiryLabel(row: ClientRecord) {
  333. if (!row.expiryTime) return '∞';
  334. if (row.expiryTime < 0) {
  335. const days = Math.round(row.expiryTime / -86400000);
  336. return `${t('pages.clients.delayedStart')}: ${days}d`;
  337. }
  338. return IntlUtil.formatDate(row.expiryTime, datepicker);
  339. }
  340. function expiryRelative(row: ClientRecord) {
  341. if (!row.expiryTime) return '';
  342. if (row.expiryTime < 0) {
  343. const days = Math.round(row.expiryTime / -86400000);
  344. return `${days}d`;
  345. }
  346. return IntlUtil.formatRelativeTime(row.expiryTime);
  347. }
  348. function expiryColor(row: ClientRecord): string {
  349. if (!row.expiryTime) return 'purple';
  350. if (row.expiryTime < 0) return 'blue';
  351. const now = Date.now();
  352. if (row.expiryTime <= now) return 'red';
  353. if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
  354. return 'green';
  355. }
  356. async function onToggleEnable(row: ClientRecord, next: boolean) {
  357. setTogglingEmail(row.email);
  358. try {
  359. const msg = await setEnable(row, next);
  360. if (!msg?.success) {
  361. messageApi.error(msg?.msg || t('somethingWentWrong'));
  362. }
  363. } finally {
  364. setTogglingEmail(null);
  365. }
  366. }
  367. function onAdd() {
  368. setFormMode('add');
  369. setEditingClient(null);
  370. setEditingAttachedIds([]);
  371. setFormOpen(true);
  372. }
  373. async function onEdit(row: ClientRecord) {
  374. setFormMode('edit');
  375. // Paged list omits per-client secrets to keep the row payload tiny;
  376. // edit needs them, so fetch the full record first.
  377. const full = await hydrate(row.email);
  378. const merged: ClientRecord = full ? { ...row, ...full.client } : { ...row };
  379. setEditingClient(merged);
  380. const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
  381. setEditingAttachedIds([...ids]);
  382. setFormOpen(true);
  383. }
  384. function onDelete(row: ClientRecord) {
  385. modal.confirm({
  386. title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
  387. content: t('pages.clients.deleteConfirmContent'),
  388. okText: t('delete'),
  389. okType: 'danger',
  390. cancelText: t('cancel'),
  391. onOk: async () => {
  392. const msg = await remove(row.email);
  393. if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
  394. },
  395. });
  396. }
  397. function onResetTraffic(row: ClientRecord) {
  398. if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
  399. messageApi.warning(t('pages.clients.resetNotPossible'));
  400. return;
  401. }
  402. modal.confirm({
  403. title: `${t('pages.inbounds.resetTraffic')} — ${row.email}`,
  404. content: t('pages.inbounds.resetTrafficContent'),
  405. okText: t('reset'),
  406. cancelText: t('cancel'),
  407. onOk: async () => {
  408. const msg = await resetTraffic(row);
  409. if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
  410. },
  411. });
  412. }
  413. async function onShowInfo(row: ClientRecord) {
  414. const full = await hydrate(row.email);
  415. setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
  416. setInfoOpen(true);
  417. }
  418. async function onShowQr(row: ClientRecord) {
  419. const full = await hydrate(row.email);
  420. setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
  421. setQrOpen(true);
  422. }
  423. function onResetAllTraffics() {
  424. modal.confirm({
  425. title: t('pages.clients.resetAllTrafficsTitle'),
  426. content: t('pages.clients.resetAllTrafficsContent'),
  427. okText: t('reset'),
  428. okType: 'danger',
  429. cancelText: t('cancel'),
  430. onOk: async () => {
  431. const msg = await resetAllTraffics();
  432. if (msg?.success) messageApi.success(t('pages.clients.toasts.allTrafficsReset'));
  433. },
  434. });
  435. }
  436. function onDelDepleted() {
  437. modal.confirm({
  438. title: t('pages.clients.delDepletedConfirmTitle'),
  439. content: t('pages.clients.delDepletedConfirmContent'),
  440. okText: t('delete'),
  441. okType: 'danger',
  442. cancelText: t('cancel'),
  443. onOk: async () => {
  444. const msg = await delDepleted();
  445. if (msg?.success) {
  446. const deleted = msg.obj?.deleted ?? 0;
  447. messageApi.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
  448. }
  449. },
  450. });
  451. }
  452. function onBulkUngroup() {
  453. const emails = [...selectedRowKeys];
  454. if (emails.length === 0) return;
  455. modal.confirm({
  456. title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
  457. content: t('pages.clients.ungroupConfirmContent'),
  458. okText: t('confirm'),
  459. okType: 'danger',
  460. cancelText: t('cancel'),
  461. onOk: async () => {
  462. const msg = await bulkRemoveFromGroup(emails);
  463. if (msg?.success) {
  464. setSelectedRowKeys([]);
  465. const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
  466. messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
  467. }
  468. },
  469. });
  470. }
  471. function onBulkDelete() {
  472. const emails = [...selectedRowKeys];
  473. if (emails.length === 0) return;
  474. modal.confirm({
  475. title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
  476. content: t('pages.clients.bulkDeleteConfirmContent'),
  477. okText: t('delete'),
  478. okType: 'danger',
  479. cancelText: t('cancel'),
  480. onOk: async () => {
  481. const msg = await bulkDelete(emails);
  482. setSelectedRowKeys([]);
  483. const ok = msg?.obj?.deleted ?? 0;
  484. const skipped = msg?.obj?.skipped ?? [];
  485. const failed = skipped.length;
  486. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  487. if (failed === 0 && msg?.success) {
  488. messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
  489. } else {
  490. messageApi.warning(firstError
  491. ? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
  492. : t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
  493. }
  494. },
  495. });
  496. }
  497. const onSave = useCallback(async (
  498. payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
  499. meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
  500. ) => {
  501. if (!meta.isEdit) {
  502. return create(payload);
  503. }
  504. const updateMsg = await update(meta.email, payload);
  505. if (!updateMsg?.success) return updateMsg;
  506. if (Array.isArray(meta.attach) && meta.attach.length > 0) {
  507. const r = await attach(meta.email, meta.attach);
  508. if (!r?.success) return r;
  509. }
  510. if (Array.isArray(meta.detach) && meta.detach.length > 0) {
  511. const r = await detach(meta.email, meta.detach);
  512. if (!r?.success) return r;
  513. }
  514. return updateMsg;
  515. }, [create, update, attach, detach]);
  516. const pageClass = useMemo(() => {
  517. const classes = ['clients-page'];
  518. if (isDark) classes.push('is-dark');
  519. if (isUltra) classes.push('is-ultra');
  520. return classes.join(' ');
  521. }, [isDark, isUltra]);
  522. const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
  523. if (pag?.current) setCurrentPage(pag.current);
  524. if (pag?.pageSize) setTablePageSize(pag.pageSize);
  525. };
  526. const columns = useMemo<ColumnsType<ClientRecord>>(() => [
  527. {
  528. title: t('pages.clients.actions'),
  529. key: 'actions',
  530. width: 200,
  531. render: (_v, record) => (
  532. <Space size={4}>
  533. <Tooltip title={t('pages.clients.qrCode')}>
  534. <Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
  535. </Tooltip>
  536. <Tooltip title={t('pages.clients.clientInfo')}>
  537. <Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
  538. </Tooltip>
  539. <Tooltip title={t('pages.inbounds.resetTraffic')}>
  540. <Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
  541. </Tooltip>
  542. <Tooltip title={t('edit')}>
  543. <Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
  544. </Tooltip>
  545. <Tooltip title={t('delete')}>
  546. <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
  547. </Tooltip>
  548. </Space>
  549. ),
  550. },
  551. {
  552. title: t('pages.clients.enabled'),
  553. key: 'enable',
  554. width: 80,
  555. render: (_v, record) => (
  556. <Switch
  557. checked={!!record.enable}
  558. size="small"
  559. loading={togglingEmail === record.email}
  560. onChange={(next) => onToggleEnable(record, next)}
  561. />
  562. ),
  563. },
  564. {
  565. title: t('pages.clients.online'),
  566. key: 'online',
  567. width: 90,
  568. render: (_v, record) => {
  569. const bucket = clientBucket(record);
  570. if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
  571. if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
  572. if (!record.enable) return <Tag>{t('disabled')}</Tag>;
  573. if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
  574. return <Tag>{t('pages.clients.offline')}</Tag>;
  575. },
  576. },
  577. {
  578. title: t('pages.clients.client'),
  579. key: 'email',
  580. render: (_v, record) => (
  581. <div className="email-cell">
  582. <span className="email">{record.email}</span>
  583. {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
  584. {record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
  585. </div>
  586. ),
  587. },
  588. {
  589. title: t('pages.clients.group'),
  590. key: 'group',
  591. width: 130,
  592. hidden: allGroups.length === 0,
  593. render: (_v, record) => {
  594. if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
  595. const isActive = filters.groups.includes(record.group);
  596. return (
  597. <Tag
  598. color="geekblue"
  599. style={{ margin: 0, cursor: 'pointer', opacity: isActive ? 0.6 : 1 }}
  600. onClick={(e) => {
  601. e.stopPropagation();
  602. if (!isActive) {
  603. setFilters({ ...filters, groups: [...filters.groups, record.group!] });
  604. }
  605. }}
  606. >
  607. {record.group}
  608. </Tag>
  609. );
  610. },
  611. },
  612. {
  613. title: t('pages.clients.attachedInbounds'),
  614. key: 'inboundIds',
  615. width: 170,
  616. render: (_v, record) => {
  617. const ids = record.inboundIds || [];
  618. if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
  619. const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
  620. const overflow = ids.slice(INBOUND_CHIP_LIMIT);
  621. const chip = (id: number, compact: boolean) => {
  622. const ib = inboundsById[id];
  623. const proto = (ib?.protocol || '').toLowerCase();
  624. const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
  625. const compactLabel = ib?.tag ?? '';
  626. return (
  627. <Tooltip key={id} title={inboundLabel(id)}>
  628. <Tag color={color} style={{ margin: 2 }}>
  629. {compact ? compactLabel : inboundLabel(id)}
  630. </Tag>
  631. </Tooltip>
  632. );
  633. };
  634. return (
  635. <>
  636. {visible.map((id) => chip(id, true))}
  637. {overflow.length > 0 && (
  638. <Popover
  639. trigger="click"
  640. placement="bottomRight"
  641. content={
  642. <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
  643. {overflow.map((id) => chip(id, false))}
  644. </div>
  645. }
  646. >
  647. <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
  648. +{overflow.length}
  649. </Tag>
  650. </Popover>
  651. )}
  652. </>
  653. );
  654. },
  655. },
  656. {
  657. title: t('pages.clients.traffic'),
  658. key: 'traffic',
  659. render: (_v, record) => trafficLabel(record),
  660. },
  661. {
  662. title: t('pages.clients.remaining'),
  663. key: 'remaining',
  664. width: 130,
  665. render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
  666. },
  667. {
  668. title: t('pages.clients.duration'),
  669. key: 'expiryTime',
  670. render: (_v, record) => (
  671. <Tooltip title={expiryLabel(record)}>
  672. <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
  673. </Tooltip>
  674. ),
  675. },
  676. // eslint-disable-next-line react-hooks/exhaustive-deps
  677. ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups]);
  678. const tablePagination = {
  679. current: currentPage,
  680. pageSize: tablePageSize,
  681. total: filtered,
  682. showSizeChanger: filtered > 10,
  683. pageSizeOptions: ['10', '25', '50', '100', '200'],
  684. hideOnSinglePage: filtered <= tablePageSize,
  685. showTotal: (n: number) => `${n}`,
  686. };
  687. const rowSelection = {
  688. selectedRowKeys,
  689. onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as string[]),
  690. };
  691. function toggleSelect(email: string, checked: boolean) {
  692. setSelectedRowKeys((prev) => {
  693. const next = new Set(prev);
  694. if (checked) next.add(email); else next.delete(email);
  695. return Array.from(next);
  696. });
  697. }
  698. function selectAll(checked: boolean) {
  699. setSelectedRowKeys(checked ? filteredClients.map((c) => c.email) : []);
  700. }
  701. const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
  702. const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
  703. function clearOneFilter<K extends keyof ClientFilters>(key: K) {
  704. if (key === 'expiryFrom' || key === 'expiryTo') {
  705. setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
  706. return;
  707. }
  708. if (key === 'usageFromGB' || key === 'usageToGB') {
  709. setFilters({ ...filters, usageFromGB: undefined, usageToGB: undefined });
  710. return;
  711. }
  712. setFilters({ ...filters, [key]: emptyFilters()[key] });
  713. }
  714. return (
  715. <ConfigProvider theme={antdThemeConfig}>
  716. {messageContextHolder}
  717. {modalContextHolder}
  718. <Layout className={pageClass}>
  719. <AppSidebar />
  720. <Layout className="content-shell">
  721. <Layout.Content id="content-layout" className="content-area">
  722. <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
  723. {!fetched ? (
  724. <div className="loading-spacer" />
  725. ) : (
  726. <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
  727. <Col span={24}>
  728. <Card size="small" hoverable className="summary-card">
  729. <Row gutter={[16, 12]}>
  730. <Col xs={12} sm={8} md={4}>
  731. <Statistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
  732. </Col>
  733. <Col xs={12} sm={8} md={4}>
  734. <Popover
  735. title={t('online')}
  736. open={summary.online.length ? undefined : false}
  737. content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
  738. >
  739. <Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
  740. </Popover>
  741. </Col>
  742. <Col xs={12} sm={8} md={4}>
  743. <Popover
  744. title={t('depleted')}
  745. open={summary.depleted.length ? undefined : false}
  746. content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
  747. >
  748. <Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
  749. </Popover>
  750. </Col>
  751. <Col xs={12} sm={8} md={4}>
  752. <Popover
  753. title={t('depletingSoon')}
  754. open={summary.expiring.length ? undefined : false}
  755. content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
  756. >
  757. <Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
  758. </Popover>
  759. </Col>
  760. <Col xs={12} sm={8} md={4}>
  761. <Popover
  762. title={t('disabled')}
  763. open={summary.deactive.length ? undefined : false}
  764. content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
  765. >
  766. <Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
  767. </Popover>
  768. </Col>
  769. <Col xs={12} sm={8} md={4}>
  770. <Statistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
  771. </Col>
  772. </Row>
  773. </Card>
  774. </Col>
  775. <Col span={24}>
  776. <Card
  777. size="small"
  778. hoverable
  779. title={
  780. <div className="card-toolbar">
  781. {selectedRowKeys.length === 0 ? (
  782. <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
  783. {!isMobile && t('pages.clients.addClients')}
  784. </Button>
  785. ) : (
  786. <>
  787. <Tag
  788. color="blue"
  789. closable
  790. onClose={() => setSelectedRowKeys([])}
  791. style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
  792. >
  793. {t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
  794. </Tag>
  795. <Button icon={<UsergroupAddOutlined />} onClick={() => setBulkAttachOpen(true)}>
  796. {!isMobile && t('pages.clients.attach')}
  797. </Button>
  798. <Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
  799. {!isMobile && t('pages.clients.detach')}
  800. </Button>
  801. <Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
  802. {!isMobile && t('pages.clients.addToGroup')}
  803. </Button>
  804. <Button danger icon={<UngroupIcon />} onClick={onBulkUngroup}>
  805. {!isMobile && t('pages.clients.ungroup')}
  806. </Button>
  807. </>
  808. )}
  809. <Dropdown
  810. trigger={['click']}
  811. placement="bottomRight"
  812. menu={{
  813. items: selectedRowKeys.length > 0
  814. ? [
  815. {
  816. key: 'adjust',
  817. icon: <ClockCircleOutlined />,
  818. label: t('pages.clients.adjust'),
  819. onClick: () => setBulkAdjustOpen(true),
  820. },
  821. {
  822. key: 'subLinks',
  823. icon: <LinkOutlined />,
  824. label: t('pages.clients.subLinks'),
  825. onClick: () => setSubLinksOpen(true),
  826. },
  827. ]
  828. : [
  829. {
  830. key: 'bulk',
  831. icon: <UsergroupAddOutlined />,
  832. label: t('pages.clients.bulk'),
  833. onClick: () => setBulkAddOpen(true),
  834. },
  835. {
  836. key: 'resetAll',
  837. icon: <RetweetOutlined />,
  838. label: t('pages.clients.resetAllTraffics'),
  839. onClick: onResetAllTraffics,
  840. },
  841. {
  842. key: 'delDepleted',
  843. icon: <RestOutlined />,
  844. label: t('pages.clients.delDepleted'),
  845. danger: true,
  846. onClick: onDelDepleted,
  847. },
  848. ],
  849. }}
  850. >
  851. <Button icon={<MoreOutlined />}>
  852. {!isMobile && t('more')}
  853. </Button>
  854. </Dropdown>
  855. {selectedRowKeys.length > 0 && (
  856. <Button
  857. danger
  858. icon={<DeleteOutlined />}
  859. onClick={onBulkDelete}
  860. style={{ marginInlineStart: 'auto' }}
  861. >
  862. {!isMobile && t('delete')}
  863. </Button>
  864. )}
  865. </div>
  866. }
  867. >
  868. <div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
  869. <Input
  870. value={searchKey}
  871. onChange={(e) => setSearchKey(e.target.value)}
  872. placeholder={t('pages.clients.searchPlaceholder')}
  873. allowClear
  874. prefix={<SearchOutlined />}
  875. size={isMobile ? 'small' : 'middle'}
  876. style={{ maxWidth: 320 }}
  877. />
  878. <Badge count={activeCount} size="small" offset={[-4, 4]}>
  879. <Button
  880. icon={<FilterOutlined />}
  881. size={isMobile ? 'small' : 'middle'}
  882. onClick={() => setFilterDrawerOpen(true)}
  883. type={activeCount > 0 ? 'primary' : 'default'}
  884. >
  885. {!isMobile && t('filter')}
  886. </Button>
  887. </Badge>
  888. <Select
  889. value={sortValueFor(sortColumn, sortOrder)}
  890. size={isMobile ? 'small' : 'middle'}
  891. suffixIcon={<SortAscendingOutlined />}
  892. style={{ minWidth: isMobile ? 130 : 200 }}
  893. onChange={(value) => {
  894. const opt = SORT_OPTIONS.find((o) => o.value === value);
  895. setSortColumn(opt?.column ?? null);
  896. setSortOrder(opt?.order ?? null);
  897. }}
  898. options={SORT_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
  899. />
  900. {activeCount > 0 && (
  901. <Button
  902. size={isMobile ? 'small' : 'middle'}
  903. onClick={() => setFilters(emptyFilters())}
  904. >
  905. {t('pages.clients.clearAllFilters')}
  906. </Button>
  907. )}
  908. </div>
  909. {activeCount > 0 && (
  910. <div className="filter-chips">
  911. {filters.buckets.map((b) => (
  912. <Tag
  913. key={`b-${b}`}
  914. closable
  915. onClose={() => setFilters({ ...filters, buckets: filters.buckets.filter((x) => x !== b) })}
  916. >
  917. {bucketChipLabel(b, t)}
  918. </Tag>
  919. ))}
  920. {filters.protocols.map((p) => (
  921. <Tag
  922. key={`p-${p}`}
  923. closable
  924. color="blue"
  925. onClose={() => setFilters({ ...filters, protocols: filters.protocols.filter((x) => x !== p) })}
  926. >
  927. {p}
  928. </Tag>
  929. ))}
  930. {filters.inboundIds.map((id) => (
  931. <Tag
  932. key={`i-${id}`}
  933. closable
  934. color="cyan"
  935. onClose={() => setFilters({ ...filters, inboundIds: filters.inboundIds.filter((x) => x !== id) })}
  936. >
  937. {inboundLabel(id)}
  938. </Tag>
  939. ))}
  940. {filters.groups.map((g) => (
  941. <Tag
  942. key={`g-${g}`}
  943. closable
  944. color="geekblue"
  945. onClose={() => setFilters({ ...filters, groups: filters.groups.filter((x) => x !== g) })}
  946. >
  947. {t('pages.clients.group')}: {g}
  948. </Tag>
  949. ))}
  950. {(filters.expiryFrom || filters.expiryTo) && (
  951. <Tag closable color="purple" onClose={() => clearOneFilter('expiryFrom')}>
  952. {t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'}
  953. {' → '}
  954. {filters.expiryTo ? IntlUtil.formatDate(filters.expiryTo, datepicker) : '…'}
  955. </Tag>
  956. )}
  957. {(filters.usageFromGB || filters.usageToGB) && (
  958. <Tag closable color="orange" onClose={() => clearOneFilter('usageFromGB')}>
  959. {t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}{filters.usageToGB ? `–${filters.usageToGB}` : '+'} GB
  960. </Tag>
  961. )}
  962. {filters.autoRenew && (
  963. <Tag closable color="gold" onClose={() => clearOneFilter('autoRenew')}>
  964. {t('pages.clients.renew')}: {filters.autoRenew === 'on' ? t('enabled') : t('disabled')}
  965. </Tag>
  966. )}
  967. {filters.hasTgId && (
  968. <Tag closable onClose={() => clearOneFilter('hasTgId')}>
  969. {t('pages.clients.telegramId')}: {filters.hasTgId === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
  970. </Tag>
  971. )}
  972. {filters.hasComment && (
  973. <Tag closable onClose={() => clearOneFilter('hasComment')}>
  974. {t('pages.clients.comment')}: {filters.hasComment === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
  975. </Tag>
  976. )}
  977. </div>
  978. )}
  979. {!isMobile ? (
  980. <Table<ClientRecord>
  981. columns={columns}
  982. dataSource={sortedClients}
  983. loading={loading}
  984. rowKey="email"
  985. rowSelection={rowSelection}
  986. pagination={tablePagination}
  987. size="small"
  988. scroll={{ x: 1200 }}
  989. onChange={onTableChange}
  990. locale={{
  991. emptyText: (
  992. <div className="clients-empty">
  993. <TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  994. <div>{t('noData')}</div>
  995. </div>
  996. ),
  997. }}
  998. />
  999. ) : (
  1000. <Spin spinning={loading}>
  1001. <div className="client-cards">
  1002. {filteredClients.length > 0 && (
  1003. <div className="card-bulk-bar">
  1004. <Checkbox
  1005. checked={allSelected}
  1006. indeterminate={someSelected}
  1007. onChange={(e) => selectAll(e.target.checked)}
  1008. >
  1009. {t('pages.clients.selectAll')}
  1010. </Checkbox>
  1011. {selectedRowKeys.length > 0 && (
  1012. <span className="bulk-count">{selectedRowKeys.length}</span>
  1013. )}
  1014. </div>
  1015. )}
  1016. {filteredClients.length === 0 && (
  1017. <div className="card-empty">
  1018. <TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
  1019. <div>{t('noData')}</div>
  1020. </div>
  1021. )}
  1022. {filteredClients.length > 0 && (
  1023. <div className="card-pagination">
  1024. <Pagination
  1025. current={currentPage}
  1026. pageSize={tablePageSize}
  1027. total={filtered}
  1028. showSizeChanger={filtered > 10}
  1029. pageSizeOptions={['10', '25', '50', '100', '200']}
  1030. hideOnSinglePage={filtered <= tablePageSize}
  1031. size="small"
  1032. showTotal={(n) => `${n}`}
  1033. onChange={(p, s) => {
  1034. setCurrentPage(p);
  1035. if (s && s !== tablePageSize) setTablePageSize(s);
  1036. }}
  1037. />
  1038. </div>
  1039. )}
  1040. {filteredClients.map((row) => {
  1041. const bucket = clientBucket(row);
  1042. return (
  1043. <div key={row.email} className={`client-card${selectedRowKeys.includes(row.email) ? ' is-selected' : ''}`}>
  1044. <div className="card-head">
  1045. <Checkbox
  1046. checked={selectedRowKeys.includes(row.email)}
  1047. onChange={(e) => toggleSelect(row.email, e.target.checked)}
  1048. />
  1049. <Badge status={bucketBadgeStatus(bucket)} />
  1050. <span className="tag-name">{row.email}</span>
  1051. {bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
  1052. {bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
  1053. <div className="card-actions" onClick={(e) => e.stopPropagation()}>
  1054. <Tooltip title={t('pages.clients.clientInfo')}>
  1055. <InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
  1056. </Tooltip>
  1057. <Switch
  1058. checked={!!row.enable}
  1059. size="small"
  1060. loading={togglingEmail === row.email}
  1061. onChange={(next) => onToggleEnable(row, next)}
  1062. />
  1063. <Dropdown
  1064. trigger={['click']}
  1065. placement="bottomRight"
  1066. menu={{
  1067. items: [
  1068. {
  1069. key: 'qr',
  1070. label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
  1071. onClick: () => onShowQr(row),
  1072. },
  1073. {
  1074. key: 'reset',
  1075. label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
  1076. onClick: () => onResetTraffic(row),
  1077. },
  1078. {
  1079. key: 'edit',
  1080. label: <><EditOutlined /> {t('edit')}</>,
  1081. onClick: () => onEdit(row),
  1082. },
  1083. {
  1084. key: 'delete',
  1085. danger: true,
  1086. label: <><DeleteOutlined /> {t('delete')}</>,
  1087. onClick: () => onDelete(row),
  1088. },
  1089. ],
  1090. }}
  1091. >
  1092. <MoreOutlined className="row-action-trigger" />
  1093. </Dropdown>
  1094. </div>
  1095. </div>
  1096. </div>
  1097. );
  1098. })}
  1099. </div>
  1100. </Spin>
  1101. )}
  1102. </Card>
  1103. </Col>
  1104. </Row>
  1105. )}
  1106. </Spin>
  1107. </Layout.Content>
  1108. </Layout>
  1109. <LazyMount when={formOpen}>
  1110. <ClientFormModal
  1111. open={formOpen}
  1112. mode={formMode}
  1113. client={editingClient}
  1114. attachedIds={editingAttachedIds}
  1115. inbounds={inbounds}
  1116. ipLimitEnable={ipLimitEnable}
  1117. tgBotEnable={tgBotEnable}
  1118. groups={allGroups}
  1119. save={onSave}
  1120. onOpenChange={setFormOpen}
  1121. />
  1122. </LazyMount>
  1123. <LazyMount when={infoOpen}>
  1124. <ClientInfoModal
  1125. open={infoOpen}
  1126. client={infoClient}
  1127. inboundsById={inboundsById}
  1128. isOnline={infoClient ? isOnline(infoClient.email) : false}
  1129. subSettings={subSettings}
  1130. onOpenChange={setInfoOpen}
  1131. />
  1132. </LazyMount>
  1133. <LazyMount when={qrOpen}>
  1134. <ClientQrModal
  1135. open={qrOpen}
  1136. client={qrClient}
  1137. subSettings={subSettings}
  1138. onOpenChange={setQrOpen}
  1139. />
  1140. </LazyMount>
  1141. <LazyMount when={bulkAddOpen}>
  1142. <ClientBulkAddModal
  1143. open={bulkAddOpen}
  1144. inbounds={inbounds}
  1145. ipLimitEnable={ipLimitEnable}
  1146. groups={allGroups}
  1147. onOpenChange={setBulkAddOpen}
  1148. onSaved={() => setBulkAddOpen(false)}
  1149. />
  1150. </LazyMount>
  1151. <LazyMount when={bulkAdjustOpen}>
  1152. <ClientBulkAdjustModal
  1153. open={bulkAdjustOpen}
  1154. count={selectedRowKeys.length}
  1155. onOpenChange={setBulkAdjustOpen}
  1156. onSubmit={async (addDays, addBytes) => {
  1157. const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
  1158. if (msg?.success) {
  1159. setSelectedRowKeys([]);
  1160. return msg.obj ?? { adjusted: 0 };
  1161. }
  1162. return null;
  1163. }}
  1164. />
  1165. </LazyMount>
  1166. <LazyMount when={subLinksOpen}>
  1167. <SubLinksModal
  1168. open={subLinksOpen}
  1169. emails={selectedRowKeys}
  1170. clients={clients}
  1171. subSettings={subSettings}
  1172. onOpenChange={setSubLinksOpen}
  1173. />
  1174. </LazyMount>
  1175. <LazyMount when={bulkGroupOpen}>
  1176. <BulkAddToGroupModal
  1177. open={bulkGroupOpen}
  1178. count={selectedRowKeys.length}
  1179. groups={allGroups}
  1180. onOpenChange={setBulkGroupOpen}
  1181. onSubmit={async (group) => {
  1182. const msg = await bulkAddToGroup([...selectedRowKeys], group);
  1183. if (msg?.success) {
  1184. setSelectedRowKeys([]);
  1185. return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
  1186. }
  1187. return null;
  1188. }}
  1189. />
  1190. </LazyMount>
  1191. <LazyMount when={bulkAttachOpen}>
  1192. <BulkAttachInboundsModal
  1193. open={bulkAttachOpen}
  1194. count={selectedRowKeys.length}
  1195. inbounds={inbounds}
  1196. onOpenChange={setBulkAttachOpen}
  1197. onSubmit={async (inboundIds) => {
  1198. const msg = await bulkAttach([...selectedRowKeys], inboundIds);
  1199. if (msg?.success) {
  1200. setSelectedRowKeys([]);
  1201. return msg.obj ?? { attached: [], skipped: [], errors: [] };
  1202. }
  1203. return null;
  1204. }}
  1205. />
  1206. </LazyMount>
  1207. <LazyMount when={bulkDetachOpen}>
  1208. <BulkDetachInboundsModal
  1209. open={bulkDetachOpen}
  1210. count={selectedRowKeys.length}
  1211. inbounds={inbounds}
  1212. onOpenChange={setBulkDetachOpen}
  1213. onSubmit={async (inboundIds) => {
  1214. const msg = await bulkDetach([...selectedRowKeys], inboundIds);
  1215. if (msg?.success) {
  1216. setSelectedRowKeys([]);
  1217. return msg.obj ?? { detached: [], skipped: [], errors: [] };
  1218. }
  1219. return null;
  1220. }}
  1221. />
  1222. </LazyMount>
  1223. <LazyMount when={filterDrawerOpen}>
  1224. <FilterDrawer
  1225. open={filterDrawerOpen}
  1226. onOpenChange={setFilterDrawerOpen}
  1227. filters={filters}
  1228. onChange={setFilters}
  1229. inbounds={inbounds}
  1230. protocols={protocolOptions}
  1231. groups={groupOptions}
  1232. />
  1233. </LazyMount>
  1234. </Layout>
  1235. </ConfigProvider>
  1236. );
  1237. }
  1238. function bucketChipLabel(b: string, t: (k: string) => string): string {
  1239. switch (b) {
  1240. case 'active': return t('subscription.active');
  1241. case 'expiring': return t('depletingSoon');
  1242. case 'depleted': return t('depleted');
  1243. case 'deactive': return t('disabled');
  1244. case 'online': return t('online');
  1245. default: return b;
  1246. }
  1247. }