| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394 |
- import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import {
- Badge,
- Button,
- Card,
- Checkbox,
- Col,
- ConfigProvider,
- Dropdown,
- Input,
- Layout,
- Modal,
- Pagination,
- Popover,
- Result,
- Row,
- Select,
- Space,
- Spin,
- Statistic,
- Switch,
- Table,
- Tag,
- Tooltip,
- message,
- } from 'antd';
- import type { ColumnsType, TableProps } from 'antd/es/table';
- import {
- ClockCircleOutlined,
- DeleteOutlined,
- EditOutlined,
- FilterOutlined,
- InfoCircleOutlined,
- LinkOutlined,
- MoreOutlined,
- PlusOutlined,
- QrcodeOutlined,
- RestOutlined,
- RetweetOutlined,
- SearchOutlined,
- SortAscendingOutlined,
- TagsOutlined,
- TeamOutlined,
- UsergroupAddOutlined,
- UsergroupDeleteOutlined,
- } from '@ant-design/icons';
- import { useTheme } from '@/hooks/useTheme';
- import { formatInboundLabel } from '@/lib/inbounds/label';
- import { useMediaQuery } from '@/hooks/useMediaQuery';
- import { useWebSocket } from '@/hooks/useWebSocket';
- import { useClients } from '@/hooks/useClients';
- import { useNodesQuery } from '@/api/queries/useNodesQuery';
- import { useDatepicker } from '@/hooks/useDatepicker';
- import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
- import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
- import AppSidebar from '@/layouts/AppSidebar';
- import { IntlUtil, SizeFormatter } from '@/utils';
- import { setMessageInstance } from '@/utils/messageBus';
- import { LazyMount } from '@/components/utility';
- const ClientFormModal = lazy(() => import('./ClientFormModal'));
- const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
- const ClientQrModal = lazy(() => import('./ClientQrModal'));
- const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
- const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
- const FilterDrawer = lazy(() => import('./FilterDrawer'));
- const SubLinksModal = lazy(() => import('./SubLinksModal'));
- const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
- const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
- const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
- import { emptyFilters, activeFilterCount } from './filters';
- import type { ClientFilters } from './filters';
- import './ClientsPage.css';
- const FILTER_STATE_KEY = 'clientsFilterState';
- const DISABLED_PAGE_SIZE = 200;
- function UngroupIcon() {
- return (
- <span
- style={{
- position: 'relative',
- display: 'inline-flex',
- alignItems: 'center',
- justifyContent: 'center',
- width: '1em',
- height: '1em',
- }}
- >
- <TagsOutlined />
- <span
- aria-hidden="true"
- style={{
- position: 'absolute',
- inset: 0,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- pointerEvents: 'none',
- }}
- >
- <span
- style={{
- display: 'block',
- width: '125%',
- height: '1.5px',
- background: 'currentColor',
- transform: 'rotate(-45deg)',
- borderRadius: '1px',
- }}
- />
- </span>
- </span>
- );
- }
- type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
- interface PersistedFilterState {
- searchKey: string;
- filters: ClientFilters;
- sort: string;
- }
- const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
- vless: 'blue',
- vmess: 'geekblue',
- trojan: 'volcano',
- shadowsocks: 'magenta',
- hysteria: 'cyan',
- hysteria2: 'green',
- wireguard: 'gold',
- http: 'purple',
- mixed: 'lime',
- tunnel: 'orange',
- };
- const INBOUND_CHIP_LIMIT = 1;
- function readFilterState(): PersistedFilterState {
- try {
- const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
- const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
- return {
- searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '',
- filters: {
- ...emptyFilters(),
- ...fromRaw,
- buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
- protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
- inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
- nodeIds: Array.isArray(fromRaw.nodeIds) ? fromRaw.nodeIds : [],
- groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
- },
- sort: typeof raw.sort === 'string' ? raw.sort : '',
- };
- } catch {
- return { searchKey: '', filters: emptyFilters(), sort: '' };
- }
- }
- function gbToBytes(gb: number | undefined): number {
- if (!gb || gb <= 0) return 0;
- return Math.round(gb * 1024 * 1024 * 1024);
- }
- const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
- { value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' },
- { value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' },
- { value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' },
- { value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' },
- { value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' },
- { value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' },
- { value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' },
- { value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' },
- { value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' },
- ];
- const DEFAULT_SORT = SORT_OPTIONS[0];
- function sortValueFor(column: string | null, order: 'ascend' | 'descend' | null): string {
- if (!column || !order) return DEFAULT_SORT.value;
- return `${column}:${order}`;
- }
- export default function ClientsPage() {
- const { t } = useTranslation();
- const { isDark, isUltra, antdThemeConfig } = useTheme();
- const { datepicker } = useDatepicker();
- const { isMobile } = useMediaQuery();
- const [modal, modalContextHolder] = Modal.useModal();
- const [messageApi, messageContextHolder] = message.useMessage();
- useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
- const {
- clients, total, filtered,
- summary: serverSummary,
- allGroups,
- setQuery,
- inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
- tgBotEnable, expireDiff, trafficDiff, pageSize,
- create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
- resetTraffic, resetAllTraffics, delDepleted, setEnable,
- applyTrafficEvent, applyClientStatsEvent,
- refresh,
- hydrate,
- } = useClients();
- useWebSocket({
- traffic: applyTrafficEvent,
- client_stats: applyClientStatsEvent,
- });
- // Node list for the Nodes filter; the section only renders when the panel
- // actually manages nodes (#4997).
- const { nodes } = useNodesQuery();
- const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
- const [formOpen, setFormOpen] = useState(false);
- const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
- const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
- const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
- const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
- const [infoOpen, setInfoOpen] = useState(false);
- const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
- const [qrOpen, setQrOpen] = useState(false);
- const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
- const [bulkAddOpen, setBulkAddOpen] = useState(false);
- const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
- const [subLinksOpen, setSubLinksOpen] = useState(false);
- const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
- const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
- const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
- const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
- const initial = readFilterState();
- const [searchKey, setSearchKey] = useState(initial.searchKey);
- const [filters, setFilters] = useState<ClientFilters>(initial.filters);
- const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
- const initialSort = SORT_OPTIONS.find((o) => o.value === initial.sort) ?? DEFAULT_SORT;
- const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
- const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
- const [currentPage, setCurrentPage] = useState(1);
- const [tablePageSize, setTablePageSize] = useState(25);
- // debouncedSearch lags behind the input so we don't spam the server on every
- // keystroke; the search box still feels instant locally.
- const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
- useEffect(() => {
- localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
- }, [searchKey, filters, sortColumn, sortOrder]);
- useEffect(() => {
- const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
- return () => window.clearTimeout(handle);
- }, [searchKey]);
- useEffect(() => {
- // Reset to page 1 whenever a filter or sort changes — otherwise an empty
- // result set on a high page number leaves the user staring at "no clients".
- setCurrentPage(1);
- }, [debouncedSearch, filters, sortColumn, sortOrder]);
- // The node filter maps onto inbound ids client-side (#4997): the paging API
- // already accepts an inbound CSV, so nodes never have to reach the backend.
- // Sentinel 0 = "local panel" (inbounds without a nodeId).
- const effectiveInboundCsv = useMemo(() => {
- if (!filters.nodeIds.length) return filters.inboundIds.join(',');
- const nodeSet = new Set(filters.nodeIds);
- const nodeInboundIds = inbounds
- .filter((ib) => nodeSet.has(ib.nodeId ?? 0))
- .map((ib) => ib.id);
- const pool = filters.inboundIds.length
- ? nodeInboundIds.filter((id) => filters.inboundIds.includes(id))
- : nodeInboundIds;
- // Nothing matches the selected nodes: send an impossible id so the filter
- // yields an honest empty result instead of being silently ignored.
- return pool.length ? pool.join(',') : '-1';
- }, [filters.nodeIds, filters.inboundIds, inbounds]);
- useEffect(() => {
- setQuery({
- page: currentPage,
- pageSize: tablePageSize,
- search: debouncedSearch,
- filter: filters.buckets.join(','),
- protocol: filters.protocols.join(','),
- inbound: effectiveInboundCsv,
- expiryFrom: filters.expiryFrom,
- expiryTo: filters.expiryTo,
- usageFrom: gbToBytes(filters.usageFromGB),
- usageTo: gbToBytes(filters.usageToGB),
- autoRenew: filters.autoRenew || undefined,
- hasTgId: filters.hasTgId || undefined,
- hasComment: filters.hasComment || undefined,
- group: filters.groups.join(',') || undefined,
- sort: sortColumn || undefined,
- order: sortOrder || undefined,
- });
- }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
- const activeCount = activeFilterCount(filters);
- useEffect(() => {
- setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
- }, [pageSize]);
- const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
- const inboundsById = useMemo(() => {
- const out: Record<number, InboundOption> = {};
- for (const ib of inbounds) out[ib.id] = ib;
- return out;
- }, [inbounds]);
- const protocolOptions = useMemo(() => {
- const values = new Set<string>((inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x));
- return [...values].sort();
- }, [inbounds]);
- const groupOptions = useMemo(() => {
- const values = new Set<string>(allGroups);
- for (const g of filters.groups) values.add(g);
- return [...values].sort((a, b) => a.localeCompare(b));
- }, [allGroups, filters.groups]);
- const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
- function inboundLabel(id: number) {
- const ib = inboundsById[id];
- return formatInboundLabel(ib?.tag, ib?.remark);
- }
- const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
- if (!row) return null;
- const traffic = row.traffic || {};
- const used = (traffic.up || 0) + (traffic.down || 0);
- const total = row.totalGB || 0;
- const now = Date.now();
- const expired = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) <= now;
- const exhausted = total > 0 && used >= total;
- if (expired || exhausted) return 'depleted';
- if (!row.enable) return 'deactive';
- const nearExpiry = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) - now < (expireDiff || 0);
- const nearLimit = total > 0 && total - used < (trafficDiff || 0);
- if (nearExpiry || nearLimit) return 'expiring';
- return 'active';
- }, [expireDiff, trafficDiff]);
- function bucketBadgeStatus(bucket: Bucket | null): 'success' | 'warning' | 'error' | 'default' {
- switch (bucket) {
- case 'depleted': return 'error';
- case 'expiring': return 'warning';
- case 'active': return 'success';
- default: return 'default';
- }
- }
- // The list page renders rows the server already sorted, filtered, and
- // paginated. Local filtering is gone — keep the variable name so the rest
- // of the file (table dataSource, mobile cards, select-all) doesn't need
- // a rename.
- const filteredClients = clients;
- // Server-computed counts that stay stable as the user paginates/filters.
- const summary = serverSummary;
- // Sort is server-side now; the page already arrives in the requested
- // order, so we just hand it through.
- const sortedClients = filteredClients;
- function remainingLabel(row: ClientRecord) {
- const total = row.totalGB || 0;
- if (total <= 0) return '∞';
- const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
- const r = total - used;
- return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
- }
- function remainingColor(row: ClientRecord): string {
- const total = row.totalGB || 0;
- if (total <= 0) return 'purple';
- const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
- const ratio = used / total;
- if (ratio >= 1) return 'red';
- if (ratio >= 0.85) return 'orange';
- return 'green';
- }
- function expiryLabel(row: ClientRecord) {
- if (!row.expiryTime) return '∞';
- if (row.expiryTime < 0) {
- const days = Math.round(row.expiryTime / -86400000);
- return `${t('pages.clients.delayedStart')}: ${days}d`;
- }
- return IntlUtil.formatDate(row.expiryTime, datepicker);
- }
- function expiryRelative(row: ClientRecord) {
- if (!row.expiryTime) return '';
- if (row.expiryTime < 0) {
- const days = Math.round(row.expiryTime / -86400000);
- return `${days}d`;
- }
- return IntlUtil.formatRelativeTime(row.expiryTime);
- }
- function expiryColor(row: ClientRecord): string {
- if (!row.expiryTime) return 'purple';
- if (row.expiryTime < 0) return 'blue';
- const now = Date.now();
- if (row.expiryTime <= now) return 'red';
- if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
- return 'green';
- }
- async function onToggleEnable(row: ClientRecord, next: boolean) {
- setTogglingEmail(row.email);
- try {
- const msg = await setEnable(row, next);
- if (!msg?.success) {
- messageApi.error(msg?.msg || t('somethingWentWrong'));
- }
- } finally {
- setTogglingEmail(null);
- }
- }
- function onAdd() {
- setFormMode('add');
- setEditingClient(null);
- setEditingAttachedIds([]);
- setEditingExternalLinks([]);
- setFormOpen(true);
- }
- async function onEdit(row: ClientRecord) {
- setFormMode('edit');
- // Paged list omits per-client secrets to keep the row payload tiny;
- // edit needs them, so fetch the full record first.
- const full = await hydrate(row.email);
- const merged: ClientRecord = full ? { ...row, ...full.client } : { ...row };
- setEditingClient(merged);
- const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
- setEditingAttachedIds([...ids]);
- setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
- setFormOpen(true);
- }
- function onDelete(row: ClientRecord) {
- modal.confirm({
- title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
- content: t('pages.clients.deleteConfirmContent'),
- okText: t('delete'),
- okType: 'danger',
- cancelText: t('cancel'),
- onOk: async () => {
- const msg = await remove(row.email);
- if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
- },
- });
- }
- function onResetTraffic(row: ClientRecord) {
- if (!row?.email) {
- messageApi.warning(t('pages.clients.resetNotPossible'));
- return;
- }
- modal.confirm({
- title: `${t('pages.inbounds.resetTraffic')} — ${row.email}`,
- content: t('pages.inbounds.resetTrafficContent'),
- okText: t('reset'),
- cancelText: t('cancel'),
- onOk: async () => {
- const msg = await resetTraffic(row);
- if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
- },
- });
- }
- async function onShowInfo(row: ClientRecord) {
- const full = await hydrate(row.email);
- setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
- setInfoOpen(true);
- }
- async function onShowQr(row: ClientRecord) {
- const full = await hydrate(row.email);
- setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
- setQrOpen(true);
- }
- function onResetAllTraffics() {
- modal.confirm({
- title: t('pages.clients.resetAllTrafficsTitle'),
- content: t('pages.clients.resetAllTrafficsContent'),
- okText: t('reset'),
- okType: 'danger',
- cancelText: t('cancel'),
- onOk: async () => {
- const msg = await resetAllTraffics();
- if (msg?.success) messageApi.success(t('pages.clients.toasts.allTrafficsReset'));
- },
- });
- }
- function onDelDepleted() {
- modal.confirm({
- title: t('pages.clients.delDepletedConfirmTitle'),
- content: t('pages.clients.delDepletedConfirmContent'),
- okText: t('delete'),
- okType: 'danger',
- cancelText: t('cancel'),
- onOk: async () => {
- const msg = await delDepleted();
- if (msg?.success) {
- const deleted = msg.obj?.deleted ?? 0;
- messageApi.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
- }
- },
- });
- }
- function onBulkUngroup() {
- const emails = [...selectedRowKeys];
- if (emails.length === 0) return;
- modal.confirm({
- title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
- content: t('pages.clients.ungroupConfirmContent'),
- okText: t('confirm'),
- okType: 'danger',
- cancelText: t('cancel'),
- onOk: async () => {
- const msg = await bulkRemoveFromGroup(emails);
- if (msg?.success) {
- setSelectedRowKeys([]);
- const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
- messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
- }
- },
- });
- }
- function onBulkDelete() {
- const emails = [...selectedRowKeys];
- if (emails.length === 0) return;
- modal.confirm({
- title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
- content: t('pages.clients.bulkDeleteConfirmContent'),
- okText: t('delete'),
- okType: 'danger',
- cancelText: t('cancel'),
- onOk: async () => {
- const msg = await bulkDelete(emails);
- setSelectedRowKeys([]);
- const ok = msg?.obj?.deleted ?? 0;
- const skipped = msg?.obj?.skipped ?? [];
- const failed = skipped.length;
- const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
- if (failed === 0 && msg?.success) {
- messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
- } else {
- messageApi.warning(firstError
- ? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
- : t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
- }
- },
- });
- }
- const onSave = useCallback(async (
- payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
- meta:
- | { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
- | { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[] },
- ) => {
- if (!meta.isEdit) {
- const createMsg = await create(payload);
- if (!createMsg?.success) return createMsg;
- if (meta.email && meta.externalLinks.length > 0) {
- const r = await setExternalLinks(meta.email, meta.externalLinks);
- if (!r?.success) return r;
- }
- return createMsg;
- }
- const updateMsg = await update(meta.email, payload);
- if (!updateMsg?.success) return updateMsg;
- if (Array.isArray(meta.attach) && meta.attach.length > 0) {
- const r = await attach(meta.email, meta.attach);
- if (!r?.success) return r;
- }
- if (Array.isArray(meta.detach) && meta.detach.length > 0) {
- const r = await detach(meta.email, meta.detach);
- if (!r?.success) return r;
- }
- // Always replace the client's external links (an empty set clears them).
- const r = await setExternalLinks(meta.email, meta.externalLinks);
- if (!r?.success) return r;
- return updateMsg;
- }, [create, update, attach, detach, setExternalLinks]);
- const pageClass = useMemo(() => {
- const classes = ['clients-page'];
- if (isDark) classes.push('is-dark');
- if (isUltra) classes.push('is-ultra');
- return classes.join(' ');
- }, [isDark, isUltra]);
- const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
- if (pag?.current) setCurrentPage(pag.current);
- if (pag?.pageSize) setTablePageSize(pag.pageSize);
- };
- const columns = useMemo<ColumnsType<ClientRecord>>(() => [
- {
- title: t('pages.clients.actions'),
- key: 'actions',
- width: 200,
- render: (_v, record) => (
- <Space size={4}>
- <Tooltip title={t('pages.clients.qrCode')}>
- <Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
- </Tooltip>
- <Tooltip title={t('pages.clients.clientInfo')}>
- <Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
- </Tooltip>
- <Tooltip title={t('pages.inbounds.resetTraffic')}>
- <Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
- </Tooltip>
- <Tooltip title={t('edit')}>
- <Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
- </Tooltip>
- <Tooltip title={t('delete')}>
- <Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
- </Tooltip>
- </Space>
- ),
- },
- {
- title: t('pages.clients.enabled'),
- key: 'enable',
- width: 80,
- render: (_v, record) => (
- <Switch
- checked={!!record.enable}
- size="small"
- loading={togglingEmail === record.email}
- onChange={(next) => onToggleEnable(record, next)}
- />
- ),
- },
- {
- title: t('pages.clients.online'),
- key: 'online',
- width: 90,
- render: (_v, record) => {
- const bucket = clientBucket(record);
- const lastOnline = record.traffic?.lastOnline ?? 0;
- const lastOnlineTitle = `${t('lastOnline')}: ${lastOnline > 0 ? IntlUtil.formatDate(lastOnline, datepicker) : '-'}`;
- if (bucket === 'depleted') return (
- <Tooltip title={lastOnlineTitle}>
- <Tag color="red">{t('depleted')}</Tag>
- </Tooltip>
- );
- if (record.enable && isOnline(record.email)) return (
- <Tag color="green" className="dot-tag"><span className="online-dot" />{t('pages.clients.online')}</Tag>
- );
- if (!record.enable) return <Tag>{t('disabled')}</Tag>;
- if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
- return (
- <Tooltip title={lastOnlineTitle}>
- <Tag>{t('pages.clients.offline')}</Tag>
- </Tooltip>
- );
- },
- },
- {
- title: t('pages.clients.client'),
- key: 'email',
- width: 220,
- render: (_v, record) => (
- <div className="email-cell">
- <span className="email">{record.email}</span>
- {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
- {record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
- </div>
- ),
- },
- {
- title: t('pages.clients.group'),
- key: 'group',
- width: 130,
- hidden: allGroups.length === 0,
- render: (_v, record) => {
- if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
- const isActive = filters.groups.includes(record.group);
- return (
- <Tag
- color="geekblue"
- style={{ margin: 0, cursor: 'pointer', opacity: isActive ? 0.6 : 1 }}
- onClick={(e) => {
- e.stopPropagation();
- if (!isActive) {
- setFilters({ ...filters, groups: [...filters.groups, record.group!] });
- }
- }}
- >
- {record.group}
- </Tag>
- );
- },
- },
- {
- title: t('pages.clients.attachedInbounds'),
- key: 'inboundIds',
- width: 170,
- render: (_v, record) => {
- const ids = record.inboundIds || [];
- if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
- const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
- const overflow = ids.slice(INBOUND_CHIP_LIMIT);
- const chip = (id: number, compact: boolean) => {
- const ib = inboundsById[id];
- const proto = (ib?.protocol || '').toLowerCase();
- const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
- const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
- return (
- <Tooltip key={id} title={inboundLabel(id)}>
- <Tag color={color} style={{ margin: 2 }}>
- {compact ? compactLabel : inboundLabel(id)}
- </Tag>
- </Tooltip>
- );
- };
- return (
- <>
- {visible.map((id) => chip(id, true))}
- {overflow.length > 0 && (
- <Popover
- trigger="click"
- placement="bottomRight"
- content={
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
- {overflow.map((id) => chip(id, false))}
- </div>
- }
- >
- <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
- +{overflow.length}
- </Tag>
- </Popover>
- )}
- </>
- );
- },
- },
- {
- title: t('pages.clients.traffic'),
- key: 'traffic',
- width: 300,
- render: (_v, record) => (
- <ClientTrafficCell
- up={record.traffic?.up}
- down={record.traffic?.down}
- total={record.totalGB}
- enabled={record.enable}
- trafficDiff={trafficDiff}
- />
- ),
- },
- {
- title: t('pages.clients.remaining'),
- key: 'remaining',
- width: 130,
- render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
- },
- {
- title: t('pages.clients.duration'),
- key: 'expiryTime',
- width: 130,
- render: (_v, record) => (
- <Tooltip title={expiryLabel(record)}>
- <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
- </Tooltip>
- ),
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker, trafficDiff]);
- const tablePagination = {
- current: currentPage,
- pageSize: tablePageSize,
- total: filtered,
- showSizeChanger: filtered > 10,
- pageSizeOptions: ['10', '25', '50', '100', '200'],
- hideOnSinglePage: filtered <= tablePageSize,
- showTotal: (n: number) => `${n}`,
- };
- const rowSelection = {
- selectedRowKeys,
- onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as string[]),
- };
- function toggleSelect(email: string, checked: boolean) {
- setSelectedRowKeys((prev) => {
- const next = new Set(prev);
- if (checked) next.add(email); else next.delete(email);
- return Array.from(next);
- });
- }
- function selectAll(checked: boolean) {
- setSelectedRowKeys(checked ? filteredClients.map((c) => c.email) : []);
- }
- const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
- const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
- function clearOneFilter<K extends keyof ClientFilters>(key: K) {
- if (key === 'expiryFrom' || key === 'expiryTo') {
- setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
- return;
- }
- if (key === 'usageFromGB' || key === 'usageToGB') {
- setFilters({ ...filters, usageFromGB: undefined, usageToGB: undefined });
- return;
- }
- setFilters({ ...filters, [key]: emptyFilters()[key] });
- }
- return (
- <ConfigProvider theme={antdThemeConfig}>
- {messageContextHolder}
- {modalContextHolder}
- <Layout className={pageClass}>
- <AppSidebar />
- <Layout className="content-shell">
- <Layout.Content id="content-layout" className="content-area">
- <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
- {!fetched ? (
- <div className="loading-spacer" />
- ) : fetchError ? (
- <Result
- status="error"
- title={t('somethingWentWrong')}
- subTitle={fetchError}
- extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
- />
- ) : (
- <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
- <Col span={24}>
- <Card size="small" hoverable className="summary-card">
- <Row gutter={[16, 12]}>
- <Col xs={12} sm={8} md={4}>
- <Statistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
- </Col>
- <Col xs={12} sm={8} md={4}>
- <Popover
- title={t('online')}
- open={summary.online.length ? undefined : false}
- content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
- >
- <Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
- </Popover>
- </Col>
- <Col xs={12} sm={8} md={4}>
- <Popover
- title={t('depleted')}
- open={summary.depleted.length ? undefined : false}
- content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
- >
- <Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
- </Popover>
- </Col>
- <Col xs={12} sm={8} md={4}>
- <Popover
- title={t('depletingSoon')}
- open={summary.expiring.length ? undefined : false}
- content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
- >
- <Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
- </Popover>
- </Col>
- <Col xs={12} sm={8} md={4}>
- <Popover
- title={t('disabled')}
- open={summary.deactive.length ? undefined : false}
- content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
- >
- <Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
- </Popover>
- </Col>
- <Col xs={12} sm={8} md={4}>
- <Statistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
- </Col>
- </Row>
- </Card>
- </Col>
- <Col span={24}>
- <Card
- size="small"
- hoverable
- title={
- <div className="card-toolbar">
- {selectedRowKeys.length === 0 ? (
- <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
- {!isMobile && t('pages.clients.addClients')}
- </Button>
- ) : (
- <>
- <Tag
- color="blue"
- closable
- onClose={() => setSelectedRowKeys([])}
- style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
- >
- {t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
- </Tag>
- <Button icon={<UsergroupAddOutlined />} onClick={() => setBulkAttachOpen(true)}>
- {!isMobile && t('pages.clients.attach')}
- </Button>
- <Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
- {!isMobile && t('pages.clients.detach')}
- </Button>
- <Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
- {!isMobile && t('pages.clients.addToGroup')}
- </Button>
- <Button danger icon={<UngroupIcon />} onClick={onBulkUngroup}>
- {!isMobile && t('pages.clients.ungroup')}
- </Button>
- </>
- )}
- <Dropdown
- trigger={['click']}
- placement="bottomRight"
- menu={{
- items: selectedRowKeys.length > 0
- ? [
- {
- key: 'adjust',
- icon: <ClockCircleOutlined />,
- label: t('pages.clients.adjust'),
- onClick: () => setBulkAdjustOpen(true),
- },
- {
- key: 'subLinks',
- icon: <LinkOutlined />,
- label: t('pages.clients.subLinks'),
- onClick: () => setSubLinksOpen(true),
- },
- ]
- : [
- {
- key: 'bulk',
- icon: <UsergroupAddOutlined />,
- label: t('pages.clients.bulk'),
- onClick: () => setBulkAddOpen(true),
- },
- {
- key: 'resetAll',
- icon: <RetweetOutlined />,
- label: t('pages.clients.resetAllTraffics'),
- onClick: onResetAllTraffics,
- },
- {
- key: 'delDepleted',
- icon: <RestOutlined />,
- label: t('pages.clients.delDepleted'),
- danger: true,
- onClick: onDelDepleted,
- },
- ],
- }}
- >
- <Button icon={<MoreOutlined />}>
- {!isMobile && t('more')}
- </Button>
- </Dropdown>
- {selectedRowKeys.length > 0 && (
- <Button
- danger
- icon={<DeleteOutlined />}
- onClick={onBulkDelete}
- style={{ marginInlineStart: 'auto' }}
- >
- {!isMobile && t('delete')}
- </Button>
- )}
- </div>
- }
- >
- <div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
- <Input
- value={searchKey}
- onChange={(e) => setSearchKey(e.target.value)}
- placeholder={t('pages.clients.searchPlaceholder')}
- allowClear
- prefix={<SearchOutlined />}
- size={isMobile ? 'small' : 'middle'}
- style={{ maxWidth: 320 }}
- />
- <Badge count={activeCount} size="small" offset={[-4, 4]}>
- <Button
- icon={<FilterOutlined />}
- size={isMobile ? 'small' : 'middle'}
- onClick={() => setFilterDrawerOpen(true)}
- type={activeCount > 0 ? 'primary' : 'default'}
- >
- {!isMobile && t('filter')}
- </Button>
- </Badge>
- <Select
- value={sortValueFor(sortColumn, sortOrder)}
- size={isMobile ? 'small' : 'middle'}
- suffixIcon={<SortAscendingOutlined />}
- style={{ minWidth: isMobile ? 130 : 200 }}
- onChange={(value) => {
- const opt = SORT_OPTIONS.find((o) => o.value === value);
- setSortColumn(opt?.column ?? null);
- setSortOrder(opt?.order ?? null);
- }}
- options={SORT_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
- />
- {activeCount > 0 && (
- <Button
- size={isMobile ? 'small' : 'middle'}
- onClick={() => setFilters(emptyFilters())}
- >
- {t('pages.clients.clearAllFilters')}
- </Button>
- )}
- {(activeCount > 0 || debouncedSearch.trim().length > 0) && (
- <span className="filter-count">
- {t('pages.clients.showingCount', { shown: filtered, total })}
- </span>
- )}
- </div>
- {activeCount > 0 && (
- <div className="filter-chips">
- {filters.buckets.map((b) => (
- <Tag
- key={`b-${b}`}
- closable
- onClose={() => setFilters({ ...filters, buckets: filters.buckets.filter((x) => x !== b) })}
- >
- {bucketChipLabel(b, t)}
- </Tag>
- ))}
- {filters.protocols.map((p) => (
- <Tag
- key={`p-${p}`}
- closable
- color="blue"
- onClose={() => setFilters({ ...filters, protocols: filters.protocols.filter((x) => x !== p) })}
- >
- {p}
- </Tag>
- ))}
- {filters.inboundIds.map((id) => (
- <Tag
- key={`i-${id}`}
- closable
- color="cyan"
- onClose={() => setFilters({ ...filters, inboundIds: filters.inboundIds.filter((x) => x !== id) })}
- >
- {inboundLabel(id)}
- </Tag>
- ))}
- {filters.groups.map((g) => (
- <Tag
- key={`g-${g}`}
- closable
- color="geekblue"
- onClose={() => setFilters({ ...filters, groups: filters.groups.filter((x) => x !== g) })}
- >
- {t('pages.clients.group')}: {g}
- </Tag>
- ))}
- {(filters.expiryFrom || filters.expiryTo) && (
- <Tag closable color="purple" onClose={() => clearOneFilter('expiryFrom')}>
- {t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'}
- {' → '}
- {filters.expiryTo ? IntlUtil.formatDate(filters.expiryTo, datepicker) : '…'}
- </Tag>
- )}
- {(filters.usageFromGB || filters.usageToGB) && (
- <Tag closable color="orange" onClose={() => clearOneFilter('usageFromGB')}>
- {t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}{filters.usageToGB ? `–${filters.usageToGB}` : '+'} GB
- </Tag>
- )}
- {filters.autoRenew && (
- <Tag closable color="gold" onClose={() => clearOneFilter('autoRenew')}>
- {t('pages.clients.renew')}: {filters.autoRenew === 'on' ? t('enabled') : t('disabled')}
- </Tag>
- )}
- {filters.hasTgId && (
- <Tag closable onClose={() => clearOneFilter('hasTgId')}>
- {t('pages.clients.telegramId')}: {filters.hasTgId === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
- </Tag>
- )}
- {filters.hasComment && (
- <Tag closable onClose={() => clearOneFilter('hasComment')}>
- {t('pages.clients.comment')}: {filters.hasComment === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
- </Tag>
- )}
- </div>
- )}
- {!isMobile ? (
- <Table<ClientRecord>
- columns={columns}
- dataSource={sortedClients}
- loading={transitioning}
- rowKey="email"
- rowSelection={rowSelection}
- pagination={tablePagination}
- size="small"
- scroll={{ x: 1200 }}
- onChange={onTableChange}
- locale={{
- emptyText: (
- <div className="clients-empty">
- <TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
- <div>{t('noData')}</div>
- </div>
- ),
- }}
- />
- ) : (
- <Spin spinning={transitioning}>
- <div className="client-cards">
- {filteredClients.length > 0 && (
- <div className="card-bulk-bar">
- <Checkbox
- checked={allSelected}
- indeterminate={someSelected}
- onChange={(e) => selectAll(e.target.checked)}
- >
- {t('pages.clients.selectAll')}
- </Checkbox>
- {selectedRowKeys.length > 0 && (
- <span className="bulk-count">{selectedRowKeys.length}</span>
- )}
- </div>
- )}
- {filteredClients.length === 0 && (
- <div className="card-empty">
- <TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
- <div>{t('noData')}</div>
- </div>
- )}
- {filteredClients.length > 0 && (
- <div className="card-pagination">
- <Pagination
- current={currentPage}
- pageSize={tablePageSize}
- total={filtered}
- showSizeChanger={filtered > 10}
- pageSizeOptions={['10', '25', '50', '100', '200']}
- hideOnSinglePage={filtered <= tablePageSize}
- size="small"
- showTotal={(n) => `${n}`}
- onChange={(p, s) => {
- setCurrentPage(p);
- if (s && s !== tablePageSize) setTablePageSize(s);
- }}
- />
- </div>
- )}
- {filteredClients.map((row) => {
- const bucket = clientBucket(row);
- return (
- <div key={row.email} className={`client-card${selectedRowKeys.includes(row.email) ? ' is-selected' : ''}`}>
- <div className="card-head">
- <Checkbox
- checked={selectedRowKeys.includes(row.email)}
- onChange={(e) => toggleSelect(row.email, e.target.checked)}
- />
- {row.enable && bucket !== 'depleted' && isOnline(row.email)
- ? <span className="online-dot" style={{ marginInlineEnd: 0 }} />
- : <Badge status={bucketBadgeStatus(bucket)} />}
- <span className="tag-name">{row.email}</span>
- {bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
- {bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
- <div className="card-actions" onClick={(e) => e.stopPropagation()}>
- <Tooltip title={t('pages.clients.clientInfo')}>
- <InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
- </Tooltip>
- <Switch
- checked={!!row.enable}
- size="small"
- loading={togglingEmail === row.email}
- onChange={(next) => onToggleEnable(row, next)}
- />
- <Dropdown
- trigger={['click']}
- placement="bottomRight"
- menu={{
- items: [
- {
- key: 'qr',
- label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
- onClick: () => onShowQr(row),
- },
- {
- key: 'reset',
- label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
- onClick: () => onResetTraffic(row),
- },
- {
- key: 'edit',
- label: <><EditOutlined /> {t('edit')}</>,
- onClick: () => onEdit(row),
- },
- {
- key: 'delete',
- danger: true,
- label: <><DeleteOutlined /> {t('delete')}</>,
- onClick: () => onDelete(row),
- },
- ],
- }}
- >
- <MoreOutlined className="row-action-trigger" />
- </Dropdown>
- </div>
- </div>
- <ClientTrafficCell
- compact
- up={row.traffic?.up}
- down={row.traffic?.down}
- total={row.totalGB}
- enabled={row.enable}
- trafficDiff={trafficDiff}
- />
- </div>
- );
- })}
- </div>
- </Spin>
- )}
- </Card>
- </Col>
- </Row>
- )}
- </Spin>
- </Layout.Content>
- </Layout>
- <LazyMount when={formOpen}>
- <ClientFormModal
- open={formOpen}
- mode={formMode}
- client={editingClient}
- attachedIds={editingAttachedIds}
- attachedExternalLinks={editingExternalLinks}
- inbounds={inbounds}
- tgBotEnable={tgBotEnable}
- groups={allGroups}
- save={onSave}
- resetTraffic={resetTraffic}
- onOpenChange={setFormOpen}
- />
- </LazyMount>
- <LazyMount when={infoOpen}>
- <ClientInfoModal
- open={infoOpen}
- client={infoClient}
- inboundsById={inboundsById}
- isOnline={infoClient ? isOnline(infoClient.email) : false}
- subSettings={subSettings}
- onOpenChange={setInfoOpen}
- />
- </LazyMount>
- <LazyMount when={qrOpen}>
- <ClientQrModal
- open={qrOpen}
- client={qrClient}
- subSettings={subSettings}
- onOpenChange={setQrOpen}
- />
- </LazyMount>
- <LazyMount when={bulkAddOpen}>
- <ClientBulkAddModal
- open={bulkAddOpen}
- inbounds={inbounds}
- groups={allGroups}
- onOpenChange={setBulkAddOpen}
- onSaved={() => setBulkAddOpen(false)}
- />
- </LazyMount>
- <LazyMount when={bulkAdjustOpen}>
- <ClientBulkAdjustModal
- open={bulkAdjustOpen}
- count={selectedRowKeys.length}
- onOpenChange={setBulkAdjustOpen}
- onSubmit={async (addDays, addBytes) => {
- const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
- if (msg?.success) {
- setSelectedRowKeys([]);
- return msg.obj ?? { adjusted: 0 };
- }
- return null;
- }}
- />
- </LazyMount>
- <LazyMount when={subLinksOpen}>
- <SubLinksModal
- open={subLinksOpen}
- emails={selectedRowKeys}
- clients={clients}
- subSettings={subSettings}
- onOpenChange={setSubLinksOpen}
- />
- </LazyMount>
- <LazyMount when={bulkGroupOpen}>
- <BulkAddToGroupModal
- open={bulkGroupOpen}
- count={selectedRowKeys.length}
- groups={allGroups}
- onOpenChange={setBulkGroupOpen}
- onSubmit={async (group) => {
- const msg = await bulkAddToGroup([...selectedRowKeys], group);
- if (msg?.success) {
- setSelectedRowKeys([]);
- return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
- }
- return null;
- }}
- />
- </LazyMount>
- <LazyMount when={bulkAttachOpen}>
- <BulkAttachInboundsModal
- open={bulkAttachOpen}
- count={selectedRowKeys.length}
- inbounds={inbounds}
- onOpenChange={setBulkAttachOpen}
- onSubmit={async (inboundIds) => {
- const msg = await bulkAttach([...selectedRowKeys], inboundIds);
- if (msg?.success) {
- setSelectedRowKeys([]);
- return msg.obj ?? { attached: [], skipped: [], errors: [] };
- }
- return null;
- }}
- />
- </LazyMount>
- <LazyMount when={bulkDetachOpen}>
- <BulkDetachInboundsModal
- open={bulkDetachOpen}
- count={selectedRowKeys.length}
- inbounds={inbounds}
- onOpenChange={setBulkDetachOpen}
- onSubmit={async (inboundIds) => {
- const msg = await bulkDetach([...selectedRowKeys], inboundIds);
- if (msg?.success) {
- setSelectedRowKeys([]);
- return msg.obj ?? { detached: [], skipped: [], errors: [] };
- }
- return null;
- }}
- />
- </LazyMount>
- <LazyMount when={filterDrawerOpen}>
- <FilterDrawer
- open={filterDrawerOpen}
- onOpenChange={setFilterDrawerOpen}
- filters={filters}
- onChange={setFilters}
- inbounds={inbounds}
- protocols={protocolOptions}
- groups={groupOptions}
- nodes={nodes}
- />
- </LazyMount>
- </Layout>
- </ConfigProvider>
- );
- }
- function bucketChipLabel(b: string, t: (k: string) => string): string {
- switch (b) {
- case 'active': return t('subscription.active');
- case 'expiring': return t('depletingSoon');
- case 'depleted': return t('depleted');
- case 'deactive': return t('disabled');
- case 'online': return t('online');
- default: return b;
- }
- }
|