import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge, Button, Card, Checkbox, Col, ConfigProvider, Dropdown, Input, Layout, Modal, Popover, Radio, Row, Select, Space, Spin, Switch, Table, Tag, Tooltip, message, } from 'antd'; import type { ColumnsType, TableProps } from 'antd/es/table'; import { 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 CustomStatistic from '@/components/CustomStatistic'; import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; import ClientFormModal from './ClientFormModal'; import ClientInfoModal from './ClientInfoModal'; import ClientQrModal from './ClientQrModal'; import ClientBulkAddModal from './ClientBulkAddModal'; import '@/styles/page-cards.css'; import './ClientsPage.css'; const basePath = window.X_UI_BASE_PATH || ''; const requestUri = window.location.pathname; const FILTER_STATE_KEY = 'clientsFilterState'; type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; interface FilterState { enableFilter: boolean; searchKey: string; filterBy: string; protocolFilter?: string; } function readFilterState(): FilterState { try { const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); return { enableFilter: !!raw.enableFilter, searchKey: raw.searchKey || '', filterBy: raw.filterBy || '', protocolFilter: raw.protocolFilter, }; } catch { return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: 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, inbounds, onlines, loading, fetched, subSettings, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, create, update, remove, removeMany, attach, detach, resetTraffic, resetAllTraffics, delDepleted, setEnable, applyTrafficEvent, applyClientStatsEvent, applyInvalidate, } = useClients(); useWebSocket({ traffic: applyTrafficEvent, client_stats: applyClientStatsEvent, invalidate: applyInvalidate, }); 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 [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 [sortColumn, setSortColumn] = useState(null); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null); const [currentPage, setCurrentPage] = useState(1); const [tablePageSize, setTablePageSize] = useState(20); useEffect(() => { localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ enableFilter, searchKey, filterBy, protocolFilter, })); }, [enableFilter, searchKey, filterBy, protocolFilter]); 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 bucketBadgeColor(bucket: Bucket | null): string { switch (bucket) { case 'depleted': return '#ff4d4f'; case 'expiring': return '#fa8c16'; case 'deactive': return 'rgba(128,128,128,0.6)'; case 'active': return '#52c41a'; default: return 'rgba(128,128,128,0.6)'; } } function clientMatchesProtocol(row: ClientRecord, protocol?: string) { if (!protocol) return true; const ids = Array.isArray(row.inboundIds) ? row.inboundIds : []; for (const id of ids) { const ib = inboundsById[id]; if (ib && ib.protocol === protocol) return true; } return false; } const filteredClients = useMemo(() => { let rows = clients || []; if (enableFilter) { if (filterBy === 'online') { rows = rows.filter((r) => r.enable && isOnline(r.email)); } else if (filterBy) { rows = rows.filter((r) => clientBucket(r) === filterBy); } } else if (!ObjectUtil.isEmpty(searchKey)) { rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey)); } if (protocolFilter) { rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter)); } return rows; // eslint-disable-next-line react-hooks/exhaustive-deps }, [clients, enableFilter, filterBy, searchKey, protocolFilter, clientBucket]); const summary = useMemo(() => { const rows = clients || []; const deactive: string[] = []; const depleted: string[] = []; const expiring: string[] = []; const online: string[] = []; let active = 0; for (const row of rows) { const bucket = clientBucket(row); if (bucket === 'deactive') deactive.push(row.email); else if (bucket === 'depleted') depleted.push(row.email); else if (bucket === 'expiring') expiring.push(row.email); else if (bucket === 'active') active++; if (row.enable && isOnline(row.email)) online.push(row.email); } return { total: rows.length, active, deactive, depleted, expiring, online }; }, [clients, clientBucket, isOnline]); const sortFns: Record number> = { enable: (a, b) => Number(a.enable) - Number(b.enable), email: (a, b) => (a.email || '').localeCompare(b.email || ''), inboundIds: (a, b) => (a.inboundIds?.length || 0) - (b.inboundIds?.length || 0), traffic: (a, b) => { const ua = (a.traffic?.up || 0) + (a.traffic?.down || 0); const ub = (b.traffic?.up || 0) + (b.traffic?.down || 0); return ua - ub; }, remaining: (a, b) => { const ra = (a.totalGB || 0) > 0 ? (a.totalGB || 0) - ((a.traffic?.up || 0) + (a.traffic?.down || 0)) : Infinity; const rb = (b.totalGB || 0) > 0 ? (b.totalGB || 0) - ((b.traffic?.up || 0) + (b.traffic?.down || 0)) : Infinity; return ra - rb; }, expiryTime: (a, b) => { const ea = (a.expiryTime ?? 0) > 0 ? (a.expiryTime ?? 0) : Infinity; const eb = (b.expiryTime ?? 0) > 0 ? (b.expiryTime ?? 0) : Infinity; return ea - eb; }, }; const sortedClients = useMemo(() => { if (!sortColumn || !sortOrder) return filteredClients; const fn = sortFns[sortColumn]; if (!fn) return filteredClients; const sorted = [...filteredClients].sort(fn); return sortOrder === 'descend' ? sorted.reverse() : sorted; // eslint-disable-next-line react-hooks/exhaustive-deps }, [filteredClients, sortColumn, sortOrder]); 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); } function onEdit(row: ClientRecord) { setFormMode('edit'); setEditingClient({ ...row }); setEditingAttachedIds(Array.isArray(row.inboundIds) ? [...row.inboundIds] : []); 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')); }, }); } function onShowInfo(row: ClientRecord) { setInfoClient(row); setInfoOpen(true); } function onShowQr(row: ClientRecord) { setQrClient(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 results = await removeMany(emails); setSelectedRowKeys([]); let ok = 0; let failed = 0; let firstError = ''; for (const msg of results) { if (msg?.success) ok++; else { failed++; if (!firstError && msg?.msg) firstError = msg.msg; } } if (failed === 0) { 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')} )}