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, Radio, 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, MoreOutlined, PlusOutlined, QrcodeOutlined, RestOutlined, RetweetOutlined, SearchOutlined, TeamOutlined, UserOutlined, UsergroupAddOutlined, } 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')); import './ClientsPage.css'; const FILTER_STATE_KEY = 'clientsFilterState'; type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; interface FilterState { enableFilter: boolean; searchKey: string; filterBy: string; protocolFilter?: string; inboundFilter?: number; } function readFilterState(): FilterState { try { const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined; return { enableFilter: !!raw.enableFilter, searchKey: raw.searchKey || '', filterBy: raw.filterBy || '', protocolFilter: raw.protocolFilter, inboundFilter: inb, }; } catch { return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined }; } } 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, setQuery, inbounds, onlines, loading, fetched, subSettings, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, create, update, remove, bulkDelete, bulkAdjust, attach, detach, 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 [selectedRowKeys, setSelectedRowKeys] = useState([]); const initial = readFilterState(); const [enableFilter, setEnableFilter] = useState(initial.enableFilter); const [searchKey, setSearchKey] = useState(initial.searchKey); const [filterBy, setFilterBy] = useState(initial.filterBy); const [protocolFilter, setProtocolFilter] = useState(initial.protocolFilter); const [inboundFilter, setInboundFilter] = useState(initial.inboundFilter); const [sortColumn, setSortColumn] = useState(null); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null); 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({ enableFilter, searchKey, filterBy, protocolFilter, inboundFilter, })); }, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]); 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, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]); useEffect(() => { setQuery({ page: currentPage, pageSize: tablePageSize, search: enableFilter ? '' : debouncedSearch, filter: enableFilter ? (filterBy || '') : '', protocol: protocolFilter || '', inbound: inboundFilter, sort: sortColumn || undefined, order: sortOrder || undefined, }); }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]); 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 isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]); function inboundLabel(id: number) { const ib = inboundsById[id]; if (!ib) return `#${id}`; return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`; } 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 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, _filters, sorter) => { if (pag?.current) setCurrentPage(pag.current); if (pag?.pageSize) setTablePageSize(pag.pageSize); const s = Array.isArray(sorter) ? sorter[0] : sorter; setSortColumn((s?.columnKey as string) || (s?.field as string) || null); setSortOrder((s?.order as 'ascend' | 'descend' | null) || null); }; const columns = useMemo>(() => { function sortableCol[number]>(col: T, key: string): T { return { ...col, sorter: true, showSorterTooltip: false, sortOrder: sortColumn === key ? sortOrder : null, sortDirections: ['ascend', 'descend'], }; } return [ { title: t('pages.clients.actions'), key: 'actions', width: 200, render: (_v, record) => ( {selectedRowKeys.length > 0 && ( <> )} } >
} unCheckedChildren={} /> {!enableFilter && ( setSearchKey(e.target.value)} placeholder={t('search')} autoFocus size={isMobile ? 'small' : 'middle'} style={{ maxWidth: 300 }} /> )} {enableFilter && ( setFilterBy(e.target.value)} optionType="button" buttonStyle="solid" size={isMobile ? 'small' : 'middle'} > {t('none')} {t('subscription.active')} {t('disabled')} {t('depleted')} {t('depletingSoon')} {t('online')} )} setInboundFilter(v)} allowClear showSearch optionFilterProp="label" placeholder={t('inbounds')} size={isMobile ? 'small' : 'middle'} style={{ minWidth: 160, maxWidth: 240 }} options={inbounds .filter((ib) => !protocolFilter || ib.protocol === protocolFilter) .map((ib) => ({ value: ib.id, label: ib.remark ? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})` : `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`, }))} />
{!isMobile ? ( columns={columns} dataSource={sortedClients} loading={loading} rowKey="email" rowSelection={rowSelection} pagination={tablePagination} size="small" scroll={{ x: 1200 }} onChange={onTableChange} locale={{ emptyText: (
{t('pages.clients.empty')}
), }} /> ) : (
{filteredClients.length > 0 && (
selectAll(e.target.checked)} > {t('pages.clients.selectAll')} {selectedRowKeys.length > 0 && ( {selectedRowKeys.length} )}
)} {filteredClients.length === 0 && (
{t('pages.clients.empty')}
)} {filteredClients.length > 0 && (
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); }} />
)} {filteredClients.map((row) => { const bucket = clientBucket(row); return (
toggleSelect(row.email, e.target.checked)} /> {row.email} {bucket === 'depleted' && {t('depleted')}} {bucket === 'expiring' && {t('depletingSoon')}}
e.stopPropagation()}> onShowInfo(row)} /> onToggleEnable(row, next)} /> {t('pages.clients.qrCode')}, onClick: () => onShowQr(row), }, { key: 'reset', label: <> {t('pages.inbounds.resetTraffic')}, onClick: () => onResetTraffic(row), }, { key: 'edit', label: <> {t('edit')}, onClick: () => onEdit(row), }, { key: 'delete', danger: true, label: <> {t('delete')}, onClick: () => onDelete(row), }, ], }} >
); })}
)} )} setBulkAddOpen(false)} /> { const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes); if (msg?.success) { setSelectedRowKeys([]); return msg.obj ?? { adjusted: 0 }; } return null; }} /> ); }