mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 18:24:10 +00:00
Persistent client groups
- New ClientGroup model + client_groups table that holds empty
(placeholder) groups so a user can define a label before any client
references it. ListGroups merges these with the distinct group_name
values already stored on clients and reports {name, clientCount}.
- ClientRecord gains group_name column; the model.Client wire shape
gains a matching `group` JSON field that survives the
inbound.settings → SyncInbound round-trip.
- Rename/Delete on a group mutates client_groups (rename row / delete
row) AND propagates to all matching clients in ClientRecord and in
every owning inbound's settings JSON, all in one transaction.
Bulk operations
- AssignGroup(emails, group) updates clients.group_name + patches each
affected inbound's settings JSON in one read-modify-write per inbound.
Empty group clears the label. Auto-creates the client_groups row when
the user assigns to a brand-new name.
- BulkResetTraffic(emails) loops the existing single-reset path so the
caller can zero traffic across a whole selection or a whole group.
- EmailsByGroup(name) returns just the email list (used by the groups
page to fan a single bulk action over every member).
Endpoints (all under /panel/api/clients)
- GET /groups — summaries with counts
- GET /groups/:name/emails — emails in a group
- POST /groups/create — empty placeholder group
- POST /groups/rename — rename (table + clients + JSON)
- POST /groups/delete — drop label everywhere (clients survive)
- POST /bulkAssignGroup — assign N selected clients
- POST /bulkResetTraffic — reset traffic on a list
Clients page UX
- New Group column (Actions → Client → Group → Inbounds → …) with a
click-to-filter chip.
- FilterDrawer gains a multi-select Group filter whose options come
from the new ClientPageResponse.groups field (sourced from ListGroups
so empty/placeholder groups are pickable too).
- Single-client and bulk-add forms gain a Group AutoComplete pre-loaded
with all known group names.
- New toolbar buttons when selection > 0: "Group ({n})" opens
BulkAssignGroupModal, "Sub links ({n})" opens SubLinksModal.
Sub-links export modal (new SubLinksModal.tsx)
- Table of selected clients with their subscription URL (and JSON URL
when subJsonEnable is on), per-row copy, Copy all, and Download as
sub-links-<timestamp>.txt. Warns when subscription is disabled or
none of the selected clients have a subId.
Dedicated Groups page (new pages/groups/GroupsPage.tsx)
- /groups route + sidebar entry (TagsOutlined icon) + page title key.
- Card-based layout matching Clients/Inbounds/Nodes — summary card with
Total/Grouped/Empty stats, main card with Add Group button + table.
- Per-row More dropdown (icon-first column on the left): Sub links,
Adjust (days+traffic), Reset traffic, Rename, Delete clients in
group, Delete group (keep clients). Empty groups disable the
client-targeted actions.
- Reuses SubLinksModal and ClientBulkAdjustModal — emails for the
group are fetched on demand from GET /groups/:name/emails.
Other polish
- /groups + groups-page selectors added to page-shell.css and
page-cards.css so the new page inherits the same background, padding,
card borders, hover shadow, and summary-card padding.
- .card-toolbar gains a small vertical padding so the larger toolbar
buttons (now default size, matching Inbounds) don't crowd the top of
the card-head on Clients and Groups pages.
1185 lines
48 KiB
TypeScript
1185 lines
48 KiB
TypeScript
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,
|
||
} 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 BulkAssignGroupModal = lazy(() => import('./BulkAssignGroupModal'));
|
||
import { emptyFilters, activeFilterCount } from './filters';
|
||
import type { ClientFilters } from './filters';
|
||
import './ClientsPage.css';
|
||
|
||
const FILTER_STATE_KEY = 'clientsFilterState';
|
||
|
||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||
|
||
interface PersistedFilterState {
|
||
searchKey: string;
|
||
filters: ClientFilters;
|
||
}
|
||
|
||
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 : [],
|
||
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, bulkAssignGroup, attach, detach,
|
||
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
||
applyTrafficEvent, applyClientStatsEvent,
|
||
hydrate,
|
||
} = useClients();
|
||
|
||
useWebSocket({
|
||
traffic: applyTrafficEvent,
|
||
client_stats: applyClientStatsEvent,
|
||
});
|
||
|
||
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 [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 [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 [sortColumn, setSortColumn] = useState<string | null>(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<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];
|
||
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<string, unknown> | { client: Record<string, unknown>; 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<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" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
||
</Tooltip>
|
||
<Tooltip title={t('pages.clients.moreInformation')}>
|
||
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
||
</Tooltip>
|
||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
||
</Tooltip>
|
||
<Tooltip title={t('edit')}>
|
||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||
</Tooltip>
|
||
<Tooltip title={t('delete')}>
|
||
<Button size="small" type="text" danger 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);
|
||
if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
|
||
if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
|
||
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
|
||
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
|
||
return <Tag>{t('pages.clients.offline')}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: t('pages.clients.client'),
|
||
key: 'email',
|
||
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,
|
||
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 = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
|
||
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',
|
||
render: (_v, record) => trafficLabel(record),
|
||
},
|
||
{
|
||
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',
|
||
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]);
|
||
|
||
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" />
|
||
) : (
|
||
<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">
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||
{!isMobile && t('pages.clients.addClients')}
|
||
</Button>
|
||
{selectedRowKeys.length > 0 && (
|
||
<>
|
||
<Button icon={<ClockCircleOutlined />} onClick={() => setBulkAdjustOpen(true)}>
|
||
{t('pages.clients.adjustSelected', { count: selectedRowKeys.length })}
|
||
</Button>
|
||
<Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
|
||
{t('pages.clients.assignGroupSelected', { count: selectedRowKeys.length })}
|
||
</Button>
|
||
<Button icon={<LinkOutlined />} onClick={() => setSubLinksOpen(true)}>
|
||
{t('pages.clients.subLinksSelected', { count: selectedRowKeys.length })}
|
||
</Button>
|
||
<Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>
|
||
{t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Dropdown
|
||
trigger={['click']}
|
||
placement="bottomRight"
|
||
menu={{
|
||
items: [
|
||
{
|
||
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>
|
||
</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>
|
||
)}
|
||
</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={loading}
|
||
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={loading}>
|
||
<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)}
|
||
/>
|
||
<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.moreInformation')}>
|
||
<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>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Spin>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
)}
|
||
</Spin>
|
||
</Layout.Content>
|
||
</Layout>
|
||
|
||
<LazyMount when={formOpen}>
|
||
<ClientFormModal
|
||
open={formOpen}
|
||
mode={formMode}
|
||
client={editingClient}
|
||
attachedIds={editingAttachedIds}
|
||
inbounds={inbounds}
|
||
ipLimitEnable={ipLimitEnable}
|
||
tgBotEnable={tgBotEnable}
|
||
groups={allGroups}
|
||
save={onSave}
|
||
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}
|
||
ipLimitEnable={ipLimitEnable}
|
||
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}>
|
||
<BulkAssignGroupModal
|
||
open={bulkGroupOpen}
|
||
count={selectedRowKeys.length}
|
||
groups={allGroups}
|
||
onOpenChange={setBulkGroupOpen}
|
||
onSubmit={async (group) => {
|
||
const msg = await bulkAssignGroup([...selectedRowKeys], group);
|
||
if (msg?.success) {
|
||
setSelectedRowKeys([]);
|
||
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||
}
|
||
return null;
|
||
}}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={filterDrawerOpen}>
|
||
<FilterDrawer
|
||
open={filterDrawerOpen}
|
||
onOpenChange={setFilterDrawerOpen}
|
||
filters={filters}
|
||
onChange={setFilters}
|
||
inbounds={inbounds}
|
||
protocols={protocolOptions}
|
||
groups={groupOptions}
|
||
/>
|
||
</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;
|
||
}
|
||
}
|