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