ClientsPage.tsx 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952
  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. Radio,
  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. MoreOutlined,
  36. PlusOutlined,
  37. QrcodeOutlined,
  38. RestOutlined,
  39. RetweetOutlined,
  40. SearchOutlined,
  41. TeamOutlined,
  42. UserOutlined,
  43. UsergroupAddOutlined,
  44. } from '@ant-design/icons';
  45. import { useTheme } from '@/hooks/useTheme';
  46. import { useMediaQuery } from '@/hooks/useMediaQuery';
  47. import { useWebSocket } from '@/hooks/useWebSocket';
  48. import { useClients } from '@/hooks/useClients';
  49. import { useDatepicker } from '@/hooks/useDatepicker';
  50. import type { ClientRecord, InboundOption } from '@/hooks/useClients';
  51. import AppSidebar from '@/components/AppSidebar';
  52. import { IntlUtil, SizeFormatter } from '@/utils';
  53. import { setMessageInstance } from '@/utils/messageBus';
  54. import LazyMount from '@/components/LazyMount';
  55. const ClientFormModal = lazy(() => import('./ClientFormModal'));
  56. const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
  57. const ClientQrModal = lazy(() => import('./ClientQrModal'));
  58. const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
  59. const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
  60. import './ClientsPage.css';
  61. const FILTER_STATE_KEY = 'clientsFilterState';
  62. type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
  63. interface FilterState {
  64. enableFilter: boolean;
  65. searchKey: string;
  66. filterBy: string;
  67. protocolFilter?: string;
  68. inboundFilter?: number;
  69. }
  70. function readFilterState(): FilterState {
  71. try {
  72. const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
  73. const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
  74. return {
  75. enableFilter: !!raw.enableFilter,
  76. searchKey: raw.searchKey || '',
  77. filterBy: raw.filterBy || '',
  78. protocolFilter: raw.protocolFilter,
  79. inboundFilter: inb,
  80. };
  81. } catch {
  82. return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
  83. }
  84. }
  85. export default function ClientsPage() {
  86. const { t } = useTranslation();
  87. const { isDark, isUltra, antdThemeConfig } = useTheme();
  88. const { datepicker } = useDatepicker();
  89. const { isMobile } = useMediaQuery();
  90. const [modal, modalContextHolder] = Modal.useModal();
  91. const [messageApi, messageContextHolder] = message.useMessage();
  92. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  93. const {
  94. clients, filtered,
  95. summary: serverSummary,
  96. setQuery,
  97. inbounds, onlines, loading, fetched, subSettings,
  98. ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
  99. create, update, remove, bulkDelete, bulkAdjust, attach, detach,
  100. resetTraffic, resetAllTraffics, delDepleted, setEnable,
  101. applyTrafficEvent, applyClientStatsEvent,
  102. hydrate,
  103. } = useClients();
  104. useWebSocket({
  105. traffic: applyTrafficEvent,
  106. client_stats: applyClientStatsEvent,
  107. });
  108. const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
  109. const [formOpen, setFormOpen] = useState(false);
  110. const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
  111. const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
  112. const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
  113. const [infoOpen, setInfoOpen] = useState(false);
  114. const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
  115. const [qrOpen, setQrOpen] = useState(false);
  116. const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
  117. const [bulkAddOpen, setBulkAddOpen] = useState(false);
  118. const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
  119. const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
  120. const initial = readFilterState();
  121. const [enableFilter, setEnableFilter] = useState(initial.enableFilter);
  122. const [searchKey, setSearchKey] = useState(initial.searchKey);
  123. const [filterBy, setFilterBy] = useState(initial.filterBy);
  124. const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
  125. const [inboundFilter, setInboundFilter] = useState<number | undefined>(initial.inboundFilter);
  126. const [sortColumn, setSortColumn] = useState<string | null>(null);
  127. const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
  128. const [currentPage, setCurrentPage] = useState(1);
  129. const [tablePageSize, setTablePageSize] = useState(25);
  130. // debouncedSearch lags behind the input so we don't spam the server on every
  131. // keystroke; the search box still feels instant locally.
  132. const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
  133. useEffect(() => {
  134. localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
  135. enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
  136. }));
  137. }, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
  138. useEffect(() => {
  139. const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
  140. return () => window.clearTimeout(handle);
  141. }, [searchKey]);
  142. useEffect(() => {
  143. // Reset to page 1 whenever a filter or sort changes — otherwise an empty
  144. // result set on a high page number leaves the user staring at "no clients".
  145. setCurrentPage(1);
  146. }, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
  147. useEffect(() => {
  148. setQuery({
  149. page: currentPage,
  150. pageSize: tablePageSize,
  151. search: enableFilter ? '' : debouncedSearch,
  152. filter: enableFilter ? (filterBy || '') : '',
  153. protocol: protocolFilter || '',
  154. inbound: inboundFilter,
  155. sort: sortColumn || undefined,
  156. order: sortOrder || undefined,
  157. });
  158. }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
  159. useEffect(() => {
  160. if (pageSize > 0) {
  161. setTablePageSize(pageSize);
  162. }
  163. }, [pageSize]);
  164. const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
  165. const inboundsById = useMemo(() => {
  166. const out: Record<number, InboundOption> = {};
  167. for (const ib of inbounds) out[ib.id] = ib;
  168. return out;
  169. }, [inbounds]);
  170. const protocolOptions = useMemo(() => {
  171. const values = new Set<string>((inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x));
  172. return [...values].sort();
  173. }, [inbounds]);
  174. const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
  175. function inboundLabel(id: number) {
  176. const ib = inboundsById[id];
  177. if (!ib) return `#${id}`;
  178. return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
  179. }
  180. const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
  181. if (!row) return null;
  182. const traffic = row.traffic || {};
  183. const used = (traffic.up || 0) + (traffic.down || 0);
  184. const total = row.totalGB || 0;
  185. const now = Date.now();
  186. const expired = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) <= now;
  187. const exhausted = total > 0 && used >= total;
  188. if (expired || exhausted) return 'depleted';
  189. if (!row.enable) return 'deactive';
  190. const nearExpiry = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) - now < (expireDiff || 0);
  191. const nearLimit = total > 0 && total - used < (trafficDiff || 0);
  192. if (nearExpiry || nearLimit) return 'expiring';
  193. return 'active';
  194. }, [expireDiff, trafficDiff]);
  195. function bucketBadgeStatus(bucket: Bucket | null): 'success' | 'warning' | 'error' | 'default' {
  196. switch (bucket) {
  197. case 'depleted': return 'error';
  198. case 'expiring': return 'warning';
  199. case 'active': return 'success';
  200. default: return 'default';
  201. }
  202. }
  203. // The list page renders rows the server already sorted, filtered, and
  204. // paginated. Local filtering is gone — keep the variable name so the rest
  205. // of the file (table dataSource, mobile cards, select-all) doesn't need
  206. // a rename.
  207. const filteredClients = clients;
  208. // Server-computed counts that stay stable as the user paginates/filters.
  209. const summary = serverSummary;
  210. // Sort is server-side now; the page already arrives in the requested
  211. // order, so we just hand it through.
  212. const sortedClients = filteredClients;
  213. function trafficLabel(row: ClientRecord) {
  214. const t0 = row.traffic;
  215. if (!t0) return '-';
  216. const used = (t0.up || 0) + (t0.down || 0);
  217. const total = row.totalGB || 0;
  218. if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
  219. return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
  220. }
  221. function remainingLabel(row: ClientRecord) {
  222. const total = row.totalGB || 0;
  223. if (total <= 0) return '∞';
  224. const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
  225. const r = total - used;
  226. return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
  227. }
  228. function remainingColor(row: ClientRecord): string {
  229. const total = row.totalGB || 0;
  230. if (total <= 0) return 'purple';
  231. const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
  232. const ratio = used / total;
  233. if (ratio >= 1) return 'red';
  234. if (ratio >= 0.85) return 'orange';
  235. return 'green';
  236. }
  237. function expiryLabel(row: ClientRecord) {
  238. if (!row.expiryTime) return '∞';
  239. if (row.expiryTime < 0) {
  240. const days = Math.round(row.expiryTime / -86400000);
  241. return `${t('pages.clients.delayedStart')}: ${days}d`;
  242. }
  243. return IntlUtil.formatDate(row.expiryTime, datepicker);
  244. }
  245. function expiryRelative(row: ClientRecord) {
  246. if (!row.expiryTime) return '';
  247. if (row.expiryTime < 0) {
  248. const days = Math.round(row.expiryTime / -86400000);
  249. return `${days}d`;
  250. }
  251. return IntlUtil.formatRelativeTime(row.expiryTime);
  252. }
  253. function expiryColor(row: ClientRecord): string {
  254. if (!row.expiryTime) return 'purple';
  255. if (row.expiryTime < 0) return 'blue';
  256. const now = Date.now();
  257. if (row.expiryTime <= now) return 'red';
  258. if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
  259. return 'green';
  260. }
  261. async function onToggleEnable(row: ClientRecord, next: boolean) {
  262. setTogglingEmail(row.email);
  263. try {
  264. const msg = await setEnable(row, next);
  265. if (!msg?.success) {
  266. messageApi.error(msg?.msg || t('somethingWentWrong'));
  267. }
  268. } finally {
  269. setTogglingEmail(null);
  270. }
  271. }
  272. function onAdd() {
  273. setFormMode('add');
  274. setEditingClient(null);
  275. setEditingAttachedIds([]);
  276. setFormOpen(true);
  277. }
  278. async function onEdit(row: ClientRecord) {
  279. setFormMode('edit');
  280. // Paged list omits per-client secrets to keep the row payload tiny;
  281. // edit needs them, so fetch the full record first.
  282. const full = await hydrate(row.email);
  283. const merged: ClientRecord = full ? { ...row, ...full.client } : { ...row };
  284. setEditingClient(merged);
  285. const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
  286. setEditingAttachedIds([...ids]);
  287. setFormOpen(true);
  288. }
  289. function onDelete(row: ClientRecord) {
  290. modal.confirm({
  291. title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
  292. content: t('pages.clients.deleteConfirmContent'),
  293. okText: t('delete'),
  294. okType: 'danger',
  295. cancelText: t('cancel'),
  296. onOk: async () => {
  297. const msg = await remove(row.email);
  298. if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
  299. },
  300. });
  301. }
  302. function onResetTraffic(row: ClientRecord) {
  303. if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
  304. messageApi.warning(t('pages.clients.resetNotPossible'));
  305. return;
  306. }
  307. modal.confirm({
  308. title: `${t('pages.inbounds.resetTraffic')} — ${row.email}`,
  309. content: t('pages.inbounds.resetTrafficContent'),
  310. okText: t('reset'),
  311. cancelText: t('cancel'),
  312. onOk: async () => {
  313. const msg = await resetTraffic(row);
  314. if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
  315. },
  316. });
  317. }
  318. async function onShowInfo(row: ClientRecord) {
  319. const full = await hydrate(row.email);
  320. setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
  321. setInfoOpen(true);
  322. }
  323. async function onShowQr(row: ClientRecord) {
  324. const full = await hydrate(row.email);
  325. setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
  326. setQrOpen(true);
  327. }
  328. function onResetAllTraffics() {
  329. modal.confirm({
  330. title: t('pages.clients.resetAllTrafficsTitle'),
  331. content: t('pages.clients.resetAllTrafficsContent'),
  332. okText: t('reset'),
  333. okType: 'danger',
  334. cancelText: t('cancel'),
  335. onOk: async () => {
  336. const msg = await resetAllTraffics();
  337. if (msg?.success) messageApi.success(t('pages.clients.toasts.allTrafficsReset'));
  338. },
  339. });
  340. }
  341. function onDelDepleted() {
  342. modal.confirm({
  343. title: t('pages.clients.delDepletedConfirmTitle'),
  344. content: t('pages.clients.delDepletedConfirmContent'),
  345. okText: t('delete'),
  346. okType: 'danger',
  347. cancelText: t('cancel'),
  348. onOk: async () => {
  349. const msg = await delDepleted();
  350. if (msg?.success) {
  351. const deleted = msg.obj?.deleted ?? 0;
  352. messageApi.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
  353. }
  354. },
  355. });
  356. }
  357. function onBulkDelete() {
  358. const emails = [...selectedRowKeys];
  359. if (emails.length === 0) return;
  360. modal.confirm({
  361. title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
  362. content: t('pages.clients.bulkDeleteConfirmContent'),
  363. okText: t('delete'),
  364. okType: 'danger',
  365. cancelText: t('cancel'),
  366. onOk: async () => {
  367. const msg = await bulkDelete(emails);
  368. setSelectedRowKeys([]);
  369. const ok = msg?.obj?.deleted ?? 0;
  370. const skipped = msg?.obj?.skipped ?? [];
  371. const failed = skipped.length;
  372. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  373. if (failed === 0 && msg?.success) {
  374. messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
  375. } else {
  376. messageApi.warning(firstError
  377. ? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
  378. : t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
  379. }
  380. },
  381. });
  382. }
  383. const onSave = useCallback(async (
  384. payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
  385. meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
  386. ) => {
  387. if (!meta.isEdit) {
  388. return create(payload);
  389. }
  390. const updateMsg = await update(meta.email, payload);
  391. if (!updateMsg?.success) return updateMsg;
  392. if (Array.isArray(meta.attach) && meta.attach.length > 0) {
  393. const r = await attach(meta.email, meta.attach);
  394. if (!r?.success) return r;
  395. }
  396. if (Array.isArray(meta.detach) && meta.detach.length > 0) {
  397. const r = await detach(meta.email, meta.detach);
  398. if (!r?.success) return r;
  399. }
  400. return updateMsg;
  401. }, [create, update, attach, detach]);
  402. const pageClass = useMemo(() => {
  403. const classes = ['clients-page'];
  404. if (isDark) classes.push('is-dark');
  405. if (isUltra) classes.push('is-ultra');
  406. return classes.join(' ');
  407. }, [isDark, isUltra]);
  408. const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag, _filters, sorter) => {
  409. if (pag?.current) setCurrentPage(pag.current);
  410. if (pag?.pageSize) setTablePageSize(pag.pageSize);
  411. const s = Array.isArray(sorter) ? sorter[0] : sorter;
  412. setSortColumn((s?.columnKey as string) || (s?.field as string) || null);
  413. setSortOrder((s?.order as 'ascend' | 'descend' | null) || null);
  414. };
  415. const columns = useMemo<ColumnsType<ClientRecord>>(() => {
  416. function sortableCol<T extends ColumnsType<ClientRecord>[number]>(col: T, key: string): T {
  417. return {
  418. ...col,
  419. sorter: true,
  420. showSorterTooltip: false,
  421. sortOrder: sortColumn === key ? sortOrder : null,
  422. sortDirections: ['ascend', 'descend'],
  423. };
  424. }
  425. return [
  426. {
  427. title: t('pages.clients.actions'),
  428. key: 'actions',
  429. width: 200,
  430. render: (_v, record) => (
  431. <Space size={4}>
  432. <Tooltip title={t('pages.clients.qrCode')}>
  433. <Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
  434. </Tooltip>
  435. <Tooltip title={t('pages.clients.moreInformation')}>
  436. <Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
  437. </Tooltip>
  438. <Tooltip title={t('pages.inbounds.resetTraffic')}>
  439. <Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
  440. </Tooltip>
  441. <Tooltip title={t('edit')}>
  442. <Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
  443. </Tooltip>
  444. <Tooltip title={t('delete')}>
  445. <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
  446. </Tooltip>
  447. </Space>
  448. ),
  449. },
  450. sortableCol({
  451. title: t('pages.clients.enabled'), key: 'enable', width: 80,
  452. render: (_v, record) => (
  453. <Switch
  454. checked={!!record.enable}
  455. size="small"
  456. loading={togglingEmail === record.email}
  457. onChange={(next) => onToggleEnable(record, next)}
  458. />
  459. ),
  460. }, 'enable'),
  461. {
  462. title: t('pages.clients.online'),
  463. key: 'online',
  464. width: 90,
  465. render: (_v, record) => {
  466. const bucket = clientBucket(record);
  467. if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
  468. if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
  469. if (!record.enable) return <Tag>{t('disabled')}</Tag>;
  470. if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
  471. return <Tag>{t('pages.clients.offline')}</Tag>;
  472. },
  473. },
  474. sortableCol({
  475. title: t('pages.clients.client'),
  476. key: 'email',
  477. render: (_v, record) => (
  478. <div className="email-cell">
  479. <span className="email">{record.email}</span>
  480. {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
  481. </div>
  482. ),
  483. }, 'email'),
  484. sortableCol({
  485. title: t('pages.clients.attachedInbounds'),
  486. key: 'inboundIds',
  487. render: (_v, record) => {
  488. const ids = record.inboundIds || [];
  489. if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
  490. return ids.map((id) => (
  491. <Tag key={id} color="blue" style={{ margin: 2 }}>{inboundLabel(id)}</Tag>
  492. ));
  493. },
  494. }, 'inboundIds'),
  495. sortableCol({
  496. title: t('pages.clients.traffic'),
  497. key: 'traffic',
  498. render: (_v, record) => trafficLabel(record),
  499. }, 'traffic'),
  500. sortableCol({
  501. title: t('pages.clients.remaining'),
  502. key: 'remaining',
  503. width: 130,
  504. render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
  505. }, 'remaining'),
  506. sortableCol({
  507. title: t('pages.clients.duration'),
  508. key: 'expiryTime',
  509. render: (_v, record) => (
  510. <Tooltip title={expiryLabel(record)}>
  511. <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
  512. </Tooltip>
  513. ),
  514. }, 'expiryTime'),
  515. ];
  516. // eslint-disable-next-line react-hooks/exhaustive-deps
  517. }, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline, inboundsById]);
  518. const tablePagination = {
  519. current: currentPage,
  520. pageSize: tablePageSize,
  521. total: filtered,
  522. showSizeChanger: filtered > 10,
  523. pageSizeOptions: ['10', '25', '50', '100', '200'],
  524. hideOnSinglePage: filtered <= tablePageSize,
  525. showTotal: (n: number) => `${n}`,
  526. };
  527. const rowSelection = {
  528. selectedRowKeys,
  529. onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as string[]),
  530. };
  531. function toggleSelect(email: string, checked: boolean) {
  532. setSelectedRowKeys((prev) => {
  533. const next = new Set(prev);
  534. if (checked) next.add(email); else next.delete(email);
  535. return Array.from(next);
  536. });
  537. }
  538. function selectAll(checked: boolean) {
  539. setSelectedRowKeys(checked ? filteredClients.map((c) => c.email) : []);
  540. }
  541. const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
  542. const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
  543. function onToggleFilter(checked: boolean) {
  544. setEnableFilter(checked);
  545. if (checked) setSearchKey('');
  546. else setFilterBy('');
  547. }
  548. return (
  549. <ConfigProvider theme={antdThemeConfig}>
  550. {messageContextHolder}
  551. {modalContextHolder}
  552. <Layout className={pageClass}>
  553. <AppSidebar />
  554. <Layout className="content-shell">
  555. <Layout.Content id="content-layout" className="content-area">
  556. <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
  557. {!fetched ? (
  558. <div className="loading-spacer" />
  559. ) : (
  560. <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
  561. <Col span={24}>
  562. <Card size="small" hoverable className="summary-card">
  563. <Row gutter={[16, 12]}>
  564. <Col xs={12} sm={8} md={4}>
  565. <Statistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
  566. </Col>
  567. <Col xs={12} sm={8} md={4}>
  568. <Popover
  569. title={t('online')}
  570. open={summary.online.length ? undefined : false}
  571. content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
  572. >
  573. <Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
  574. </Popover>
  575. </Col>
  576. <Col xs={12} sm={8} md={4}>
  577. <Popover
  578. title={t('depleted')}
  579. open={summary.depleted.length ? undefined : false}
  580. content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
  581. >
  582. <Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
  583. </Popover>
  584. </Col>
  585. <Col xs={12} sm={8} md={4}>
  586. <Popover
  587. title={t('depletingSoon')}
  588. open={summary.expiring.length ? undefined : false}
  589. content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
  590. >
  591. <Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
  592. </Popover>
  593. </Col>
  594. <Col xs={12} sm={8} md={4}>
  595. <Popover
  596. title={t('disabled')}
  597. open={summary.deactive.length ? undefined : false}
  598. content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
  599. >
  600. <Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
  601. </Popover>
  602. </Col>
  603. <Col xs={12} sm={8} md={4}>
  604. <Statistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
  605. </Col>
  606. </Row>
  607. </Card>
  608. </Col>
  609. <Col span={24}>
  610. <Card
  611. size="small"
  612. hoverable
  613. title={
  614. <div className="card-toolbar">
  615. <Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
  616. {!isMobile && t('pages.clients.addClients')}
  617. </Button>
  618. <Button size="small" icon={<UsergroupAddOutlined />} onClick={() => setBulkAddOpen(true)}>
  619. {!isMobile && t('pages.clients.bulk')}
  620. </Button>
  621. {selectedRowKeys.length > 0 && (
  622. <>
  623. <Button size="small" icon={<ClockCircleOutlined />} onClick={() => setBulkAdjustOpen(true)}>
  624. {t('pages.clients.adjustSelected', { count: selectedRowKeys.length })}
  625. </Button>
  626. <Button danger size="small" icon={<DeleteOutlined />} onClick={onBulkDelete}>
  627. {t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
  628. </Button>
  629. </>
  630. )}
  631. <Button size="small" icon={<RetweetOutlined />} onClick={onResetAllTraffics}>
  632. {!isMobile && t('pages.clients.resetAllTraffics')}
  633. </Button>
  634. <Button size="small" danger icon={<RestOutlined />} onClick={onDelDepleted}>
  635. {!isMobile && t('pages.clients.delDepleted')}
  636. </Button>
  637. </div>
  638. }
  639. >
  640. <div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
  641. <Switch
  642. checked={enableFilter}
  643. onChange={onToggleFilter}
  644. checkedChildren={<SearchOutlined />}
  645. unCheckedChildren={<FilterOutlined />}
  646. />
  647. {!enableFilter && (
  648. <Input
  649. value={searchKey}
  650. onChange={(e) => setSearchKey(e.target.value)}
  651. placeholder={t('search')}
  652. autoFocus
  653. size={isMobile ? 'small' : 'middle'}
  654. style={{ maxWidth: 300 }}
  655. />
  656. )}
  657. {enableFilter && (
  658. <Radio.Group
  659. value={filterBy}
  660. onChange={(e) => setFilterBy(e.target.value)}
  661. optionType="button"
  662. buttonStyle="solid"
  663. size={isMobile ? 'small' : 'middle'}
  664. >
  665. <Radio.Button value="">{t('none')}</Radio.Button>
  666. <Radio.Button value="active">{t('subscription.active')}</Radio.Button>
  667. <Radio.Button value="deactive">{t('disabled')}</Radio.Button>
  668. <Radio.Button value="depleted">{t('depleted')}</Radio.Button>
  669. <Radio.Button value="expiring">{t('depletingSoon')}</Radio.Button>
  670. <Radio.Button value="online">{t('online')}</Radio.Button>
  671. </Radio.Group>
  672. )}
  673. <Select
  674. value={protocolFilter}
  675. onChange={(v) => {
  676. setProtocolFilter(v);
  677. if (v && inboundFilter) {
  678. const ib = inbounds.find((x) => x.id === inboundFilter);
  679. if (!ib || ib.protocol !== v) setInboundFilter(undefined);
  680. }
  681. }}
  682. allowClear
  683. placeholder={t('pages.inbounds.protocol')}
  684. size={isMobile ? 'small' : 'middle'}
  685. style={{ width: 150 }}
  686. options={protocolOptions.map((p) => ({ value: p, label: p }))}
  687. />
  688. <Select
  689. value={inboundFilter}
  690. onChange={(v) => setInboundFilter(v)}
  691. allowClear
  692. showSearch
  693. optionFilterProp="label"
  694. placeholder={t('inbounds')}
  695. size={isMobile ? 'small' : 'middle'}
  696. style={{ minWidth: 160, maxWidth: 240 }}
  697. options={inbounds
  698. .filter((ib) => !protocolFilter || ib.protocol === protocolFilter)
  699. .map((ib) => ({
  700. value: ib.id,
  701. label: ib.remark
  702. ? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
  703. : `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
  704. }))}
  705. />
  706. </div>
  707. {!isMobile ? (
  708. <Table<ClientRecord>
  709. columns={columns}
  710. dataSource={sortedClients}
  711. loading={loading}
  712. rowKey="email"
  713. rowSelection={rowSelection}
  714. pagination={tablePagination}
  715. size="small"
  716. scroll={{ x: 1200 }}
  717. onChange={onTableChange}
  718. locale={{
  719. emptyText: (
  720. <div className="clients-empty">
  721. <UserOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  722. <div>{t('pages.clients.empty')}</div>
  723. </div>
  724. ),
  725. }}
  726. />
  727. ) : (
  728. <Spin spinning={loading}>
  729. <div className="client-cards">
  730. {filteredClients.length > 0 && (
  731. <div className="card-bulk-bar">
  732. <Checkbox
  733. checked={allSelected}
  734. indeterminate={someSelected}
  735. onChange={(e) => selectAll(e.target.checked)}
  736. >
  737. {t('pages.clients.selectAll')}
  738. </Checkbox>
  739. {selectedRowKeys.length > 0 && (
  740. <span className="bulk-count">{selectedRowKeys.length}</span>
  741. )}
  742. </div>
  743. )}
  744. {filteredClients.length === 0 && (
  745. <div className="card-empty">
  746. <UserOutlined style={{ fontSize: 28, opacity: 0.5 }} />
  747. <div>{t('pages.clients.empty')}</div>
  748. </div>
  749. )}
  750. {filteredClients.length > 0 && (
  751. <div className="card-pagination">
  752. <Pagination
  753. current={currentPage}
  754. pageSize={tablePageSize}
  755. total={filtered}
  756. showSizeChanger={filtered > 10}
  757. pageSizeOptions={['10', '25', '50', '100', '200']}
  758. hideOnSinglePage={filtered <= tablePageSize}
  759. size="small"
  760. showTotal={(n) => `${n}`}
  761. onChange={(p, s) => {
  762. setCurrentPage(p);
  763. if (s && s !== tablePageSize) setTablePageSize(s);
  764. }}
  765. />
  766. </div>
  767. )}
  768. {filteredClients.map((row) => {
  769. const bucket = clientBucket(row);
  770. return (
  771. <div key={row.email} className={`client-card${selectedRowKeys.includes(row.email) ? ' is-selected' : ''}`}>
  772. <div className="card-head">
  773. <Checkbox
  774. checked={selectedRowKeys.includes(row.email)}
  775. onChange={(e) => toggleSelect(row.email, e.target.checked)}
  776. />
  777. <Badge status={bucketBadgeStatus(bucket)} />
  778. <span className="tag-name">{row.email}</span>
  779. {bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
  780. {bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
  781. <div className="card-actions" onClick={(e) => e.stopPropagation()}>
  782. <Tooltip title={t('pages.clients.moreInformation')}>
  783. <InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
  784. </Tooltip>
  785. <Switch
  786. checked={!!row.enable}
  787. size="small"
  788. loading={togglingEmail === row.email}
  789. onChange={(next) => onToggleEnable(row, next)}
  790. />
  791. <Dropdown
  792. trigger={['click']}
  793. placement="bottomRight"
  794. menu={{
  795. items: [
  796. {
  797. key: 'qr',
  798. label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
  799. onClick: () => onShowQr(row),
  800. },
  801. {
  802. key: 'reset',
  803. label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
  804. onClick: () => onResetTraffic(row),
  805. },
  806. {
  807. key: 'edit',
  808. label: <><EditOutlined /> {t('edit')}</>,
  809. onClick: () => onEdit(row),
  810. },
  811. {
  812. key: 'delete',
  813. danger: true,
  814. label: <><DeleteOutlined /> {t('delete')}</>,
  815. onClick: () => onDelete(row),
  816. },
  817. ],
  818. }}
  819. >
  820. <MoreOutlined className="row-action-trigger" />
  821. </Dropdown>
  822. </div>
  823. </div>
  824. </div>
  825. );
  826. })}
  827. </div>
  828. </Spin>
  829. )}
  830. </Card>
  831. </Col>
  832. </Row>
  833. )}
  834. </Spin>
  835. </Layout.Content>
  836. </Layout>
  837. <LazyMount when={formOpen}>
  838. <ClientFormModal
  839. open={formOpen}
  840. mode={formMode}
  841. client={editingClient}
  842. attachedIds={editingAttachedIds}
  843. inbounds={inbounds}
  844. ipLimitEnable={ipLimitEnable}
  845. tgBotEnable={tgBotEnable}
  846. save={onSave}
  847. onOpenChange={setFormOpen}
  848. />
  849. </LazyMount>
  850. <LazyMount when={infoOpen}>
  851. <ClientInfoModal
  852. open={infoOpen}
  853. client={infoClient}
  854. inboundsById={inboundsById}
  855. isOnline={infoClient ? isOnline(infoClient.email) : false}
  856. subSettings={subSettings}
  857. onOpenChange={setInfoOpen}
  858. />
  859. </LazyMount>
  860. <LazyMount when={qrOpen}>
  861. <ClientQrModal
  862. open={qrOpen}
  863. client={qrClient}
  864. subSettings={subSettings}
  865. onOpenChange={setQrOpen}
  866. />
  867. </LazyMount>
  868. <LazyMount when={bulkAddOpen}>
  869. <ClientBulkAddModal
  870. open={bulkAddOpen}
  871. inbounds={inbounds}
  872. ipLimitEnable={ipLimitEnable}
  873. onOpenChange={setBulkAddOpen}
  874. onSaved={() => setBulkAddOpen(false)}
  875. />
  876. </LazyMount>
  877. <LazyMount when={bulkAdjustOpen}>
  878. <ClientBulkAdjustModal
  879. open={bulkAdjustOpen}
  880. count={selectedRowKeys.length}
  881. onOpenChange={setBulkAdjustOpen}
  882. onSubmit={async (addDays, addBytes) => {
  883. const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
  884. if (msg?.success) {
  885. setSelectedRowKeys([]);
  886. return msg.obj ?? { adjusted: 0 };
  887. }
  888. return null;
  889. }}
  890. />
  891. </LazyMount>
  892. </Layout>
  893. </ConfigProvider>
  894. );
  895. }