ClientsPage.tsx 57 KB

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