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, 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 { useMediaQuery } from '@/hooks/useMediaQuery'; import { useWebSocket } from '@/hooks/useWebSocket'; import { useClients } from '@/hooks/useClients'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import AppSidebar from '@/components/AppSidebar'; import { IntlUtil, SizeFormatter } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; import LazyMount from '@/components/LazyMount'; 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'; function UngroupIcon() { return ( ); } type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; interface PersistedFilterState { searchKey: string; filters: ClientFilters; } const INBOUND_PROTOCOL_COLORS: Record = { 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; 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 : [], groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [], }, }; } catch { return { searchKey: '', filters: emptyFilters() }; } } 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, filtered, summary: serverSummary, allGroups, setQuery, inbounds, onlines, loading, fetched, subSettings, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach, resetTraffic, resetAllTraffics, delDepleted, setEnable, applyTrafficEvent, applyClientStatsEvent, hydrate, } = useClients(); useWebSocket({ traffic: applyTrafficEvent, client_stats: applyClientStatsEvent, }); const [togglingEmail, setTogglingEmail] = useState(null); const [formOpen, setFormOpen] = useState(false); const [formMode, setFormMode] = useState<'add' | 'edit'>('add'); const [editingClient, setEditingClient] = useState(null); const [editingAttachedIds, setEditingAttachedIds] = useState([]); const [infoOpen, setInfoOpen] = useState(false); const [infoClient, setInfoClient] = useState(null); const [qrOpen, setQrOpen] = useState(false); const [qrClient, setQrClient] = useState(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([]); const initial = readFilterState(); const [searchKey, setSearchKey] = useState(initial.searchKey); const [filters, setFilters] = useState(initial.filters); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [sortColumn, setSortColumn] = useState(DEFAULT_SORT.column); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.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 })); }, [searchKey, filters]); 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]); useEffect(() => { setQuery({ page: currentPage, pageSize: tablePageSize, search: debouncedSearch, filter: filters.buckets.join(','), protocol: filters.protocols.join(','), inbound: filters.inboundIds.join(','), 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, sortColumn, sortOrder]); const activeCount = activeFilterCount(filters); useEffect(() => { if (pageSize > 0) { setTablePageSize(pageSize); } }, [pageSize]); const onlineSet = useMemo(() => new Set(onlines || []), [onlines]); const inboundsById = useMemo(() => { const out: Record = {}; for (const ib of inbounds) out[ib.id] = ib; return out; }, [inbounds]); const protocolOptions = useMemo(() => { const values = new Set((inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x)); return [...values].sort(); }, [inbounds]); const groupOptions = useMemo(() => { const values = new Set(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 ib?.tag ?? ''; } 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 trafficLabel(row: ClientRecord) { const t0 = row.traffic; if (!t0) return '-'; const used = (t0.up || 0) + (t0.down || 0); const total = row.totalGB || 0; if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`; return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`; } 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([]); 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]); 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 || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) { 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 | { client: Record; inboundIds: number[] }, meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] }, ) => { if (!meta.isEdit) { return create(payload); } 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; } return updateMsg; }, [create, update, attach, detach]); 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['onChange']> = (pag) => { if (pag?.current) setCurrentPage(pag.current); if (pag?.pageSize) setTablePageSize(pag.pageSize); }; const columns = useMemo>(() => [ { title: t('pages.clients.actions'), key: 'actions', width: 200, render: (_v, record) => ( ) : ( <> setSelectedRowKeys([])} style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }} > {t('pages.clients.selectedCount', { count: selectedRowKeys.length })} )} 0 ? [ { key: 'adjust', icon: , label: t('pages.clients.adjust'), onClick: () => setBulkAdjustOpen(true), }, { key: 'subLinks', icon: , label: t('pages.clients.subLinks'), onClick: () => setSubLinksOpen(true), }, ] : [ { key: 'bulk', icon: , label: t('pages.clients.bulk'), onClick: () => setBulkAddOpen(true), }, { key: 'resetAll', icon: , label: t('pages.clients.resetAllTraffics'), onClick: onResetAllTraffics, }, { key: 'delDepleted', icon: , label: t('pages.clients.delDepleted'), danger: true, onClick: onDelDepleted, }, ], }} > {selectedRowKeys.length > 0 && ( )} } >
setSearchKey(e.target.value)} placeholder={t('pages.clients.searchPlaceholder')} allowClear prefix={} size={isMobile ? 'small' : 'middle'} style={{ maxWidth: 320 }} />