ClientsPage.tsx 67 KB

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