|
@@ -13,7 +13,6 @@ import {
|
|
|
Modal,
|
|
Modal,
|
|
|
Pagination,
|
|
Pagination,
|
|
|
Popover,
|
|
Popover,
|
|
|
- Radio,
|
|
|
|
|
Row,
|
|
Row,
|
|
|
Select,
|
|
Select,
|
|
|
Space,
|
|
Space,
|
|
@@ -32,14 +31,16 @@ import {
|
|
|
EditOutlined,
|
|
EditOutlined,
|
|
|
FilterOutlined,
|
|
FilterOutlined,
|
|
|
InfoCircleOutlined,
|
|
InfoCircleOutlined,
|
|
|
|
|
+ LinkOutlined,
|
|
|
MoreOutlined,
|
|
MoreOutlined,
|
|
|
PlusOutlined,
|
|
PlusOutlined,
|
|
|
QrcodeOutlined,
|
|
QrcodeOutlined,
|
|
|
RestOutlined,
|
|
RestOutlined,
|
|
|
RetweetOutlined,
|
|
RetweetOutlined,
|
|
|
SearchOutlined,
|
|
SearchOutlined,
|
|
|
|
|
+ SortAscendingOutlined,
|
|
|
|
|
+ TagsOutlined,
|
|
|
TeamOutlined,
|
|
TeamOutlined,
|
|
|
- UserOutlined,
|
|
|
|
|
UsergroupAddOutlined,
|
|
UsergroupAddOutlined,
|
|
|
} from '@ant-design/icons';
|
|
} from '@ant-design/icons';
|
|
|
|
|
|
|
@@ -58,18 +59,20 @@ const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
|
|
|
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
|
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
|
|
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
|
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
|
|
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
|
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';
|
|
import './ClientsPage.css';
|
|
|
|
|
|
|
|
const FILTER_STATE_KEY = 'clientsFilterState';
|
|
const FILTER_STATE_KEY = 'clientsFilterState';
|
|
|
|
|
|
|
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
|
|
|
|
|
|
|
-interface FilterState {
|
|
|
|
|
- enableFilter: boolean;
|
|
|
|
|
|
|
+interface PersistedFilterState {
|
|
|
searchKey: string;
|
|
searchKey: string;
|
|
|
- filterBy: string;
|
|
|
|
|
- protocolFilter?: string;
|
|
|
|
|
- inboundFilter?: number;
|
|
|
|
|
|
|
+ filters: ClientFilters;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|
@@ -86,22 +89,50 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|
|
};
|
|
};
|
|
|
const INBOUND_CHIP_LIMIT = 1;
|
|
const INBOUND_CHIP_LIMIT = 1;
|
|
|
|
|
|
|
|
-function readFilterState(): FilterState {
|
|
|
|
|
|
|
+function readFilterState(): PersistedFilterState {
|
|
|
try {
|
|
try {
|
|
|
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
|
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
|
|
- const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
|
|
|
|
|
|
|
+ const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
|
|
|
return {
|
|
return {
|
|
|
- enableFilter: !!raw.enableFilter,
|
|
|
|
|
- searchKey: raw.searchKey || '',
|
|
|
|
|
- filterBy: raw.filterBy || '',
|
|
|
|
|
- protocolFilter: raw.protocolFilter,
|
|
|
|
|
- inboundFilter: inb,
|
|
|
|
|
|
|
+ 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 {
|
|
} catch {
|
|
|
- return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
|
|
|
|
|
|
|
+ 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() {
|
|
export default function ClientsPage() {
|
|
|
const { t } = useTranslation();
|
|
const { t } = useTranslation();
|
|
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
|
@@ -114,10 +145,11 @@ export default function ClientsPage() {
|
|
|
const {
|
|
const {
|
|
|
clients, filtered,
|
|
clients, filtered,
|
|
|
summary: serverSummary,
|
|
summary: serverSummary,
|
|
|
|
|
+ allGroups,
|
|
|
setQuery,
|
|
setQuery,
|
|
|
inbounds, onlines, loading, fetched, subSettings,
|
|
inbounds, onlines, loading, fetched, subSettings,
|
|
|
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
|
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
|
|
- create, update, remove, bulkDelete, bulkAdjust, attach, detach,
|
|
|
|
|
|
|
+ create, update, remove, bulkDelete, bulkAdjust, bulkAssignGroup, attach, detach,
|
|
|
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
|
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
|
|
applyTrafficEvent, applyClientStatsEvent,
|
|
applyTrafficEvent, applyClientStatsEvent,
|
|
|
hydrate,
|
|
hydrate,
|
|
@@ -139,17 +171,17 @@ export default function ClientsPage() {
|
|
|
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
|
|
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
|
|
|
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
|
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
|
|
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
|
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
|
|
|
|
+ const [subLinksOpen, setSubLinksOpen] = useState(false);
|
|
|
|
|
+ const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
|
|
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
|
|
|
|
|
|
|
const initial = readFilterState();
|
|
const initial = readFilterState();
|
|
|
- const [enableFilter, setEnableFilter] = useState(initial.enableFilter);
|
|
|
|
|
const [searchKey, setSearchKey] = useState(initial.searchKey);
|
|
const [searchKey, setSearchKey] = useState(initial.searchKey);
|
|
|
- const [filterBy, setFilterBy] = useState(initial.filterBy);
|
|
|
|
|
- const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
|
|
|
|
|
- const [inboundFilter, setInboundFilter] = useState<number | undefined>(initial.inboundFilter);
|
|
|
|
|
|
|
+ const [filters, setFilters] = useState<ClientFilters>(initial.filters);
|
|
|
|
|
+ const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
|
|
|
|
|
|
|
- const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
|
|
|
- const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
|
|
|
|
|
|
|
+ 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 [currentPage, setCurrentPage] = useState(1);
|
|
|
const [tablePageSize, setTablePageSize] = useState(25);
|
|
const [tablePageSize, setTablePageSize] = useState(25);
|
|
|
// debouncedSearch lags behind the input so we don't spam the server on every
|
|
// debouncedSearch lags behind the input so we don't spam the server on every
|
|
@@ -157,10 +189,8 @@ export default function ClientsPage() {
|
|
|
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
|
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
|
|
|
|
- enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
|
|
|
|
|
- }));
|
|
|
|
|
- }, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
|
|
|
|
|
|
|
+ localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters }));
|
|
|
|
|
+ }, [searchKey, filters]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
|
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
|
@@ -171,20 +201,30 @@ export default function ClientsPage() {
|
|
|
// Reset to page 1 whenever a filter or sort changes — otherwise an empty
|
|
// 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".
|
|
// result set on a high page number leaves the user staring at "no clients".
|
|
|
setCurrentPage(1);
|
|
setCurrentPage(1);
|
|
|
- }, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
|
|
|
|
|
|
|
+ }, [debouncedSearch, filters, sortColumn, sortOrder]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
setQuery({
|
|
setQuery({
|
|
|
page: currentPage,
|
|
page: currentPage,
|
|
|
pageSize: tablePageSize,
|
|
pageSize: tablePageSize,
|
|
|
- search: enableFilter ? '' : debouncedSearch,
|
|
|
|
|
- filter: enableFilter ? (filterBy || '') : '',
|
|
|
|
|
- protocol: protocolFilter || '',
|
|
|
|
|
- inbound: inboundFilter,
|
|
|
|
|
|
|
+ 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,
|
|
sort: sortColumn || undefined,
|
|
|
order: sortOrder || undefined,
|
|
order: sortOrder || undefined,
|
|
|
});
|
|
});
|
|
|
- }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
|
|
|
|
|
|
|
+ }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
|
|
|
|
|
+
|
|
|
|
|
+ const activeCount = activeFilterCount(filters);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (pageSize > 0) {
|
|
if (pageSize > 0) {
|
|
@@ -205,6 +245,12 @@ export default function ClientsPage() {
|
|
|
return [...values].sort();
|
|
return [...values].sort();
|
|
|
}, [inbounds]);
|
|
}, [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]);
|
|
const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
|
|
|
|
|
|
|
|
function inboundLabel(id: number) {
|
|
function inboundLabel(id: number) {
|
|
@@ -464,151 +510,162 @@ export default function ClientsPage() {
|
|
|
return classes.join(' ');
|
|
return classes.join(' ');
|
|
|
}, [isDark, isUltra]);
|
|
}, [isDark, isUltra]);
|
|
|
|
|
|
|
|
- const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag, _filters, sorter) => {
|
|
|
|
|
|
|
+ const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
|
|
|
if (pag?.current) setCurrentPage(pag.current);
|
|
if (pag?.current) setCurrentPage(pag.current);
|
|
|
if (pag?.pageSize) setTablePageSize(pag.pageSize);
|
|
if (pag?.pageSize) setTablePageSize(pag.pageSize);
|
|
|
- const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
|
|
|
|
- setSortColumn((s?.columnKey as string) || (s?.field as string) || null);
|
|
|
|
|
- setSortOrder((s?.order as 'ascend' | 'descend' | null) || null);
|
|
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const columns = useMemo<ColumnsType<ClientRecord>>(() => {
|
|
|
|
|
- function sortableCol<T extends ColumnsType<ClientRecord>[number]>(col: T, key: string): T {
|
|
|
|
|
- return {
|
|
|
|
|
- ...col,
|
|
|
|
|
- sorter: true,
|
|
|
|
|
- showSorterTooltip: false,
|
|
|
|
|
- sortOrder: sortColumn === key ? sortOrder : null,
|
|
|
|
|
- sortDirections: ['ascend', 'descend'],
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- return [
|
|
|
|
|
- {
|
|
|
|
|
- title: t('pages.clients.actions'),
|
|
|
|
|
- key: 'actions',
|
|
|
|
|
- width: 200,
|
|
|
|
|
- render: (_v, record) => (
|
|
|
|
|
- <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>
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ 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>;
|
|
|
},
|
|
},
|
|
|
- sortableCol({
|
|
|
|
|
- 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)}
|
|
|
|
|
- />
|
|
|
|
|
- ),
|
|
|
|
|
- }, 'enable'),
|
|
|
|
|
- {
|
|
|
|
|
- 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>
|
|
|
|
|
+ );
|
|
|
},
|
|
},
|
|
|
- sortableCol({
|
|
|
|
|
- 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>
|
|
|
|
|
- ),
|
|
|
|
|
- }, 'email'),
|
|
|
|
|
- sortableCol({
|
|
|
|
|
- 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>
|
|
|
|
|
- );
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ 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 (
|
|
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>
|
|
|
|
|
- )}
|
|
|
|
|
- </>
|
|
|
|
|
|
|
+ <Tooltip key={id} title={inboundLabel(id)}>
|
|
|
|
|
+ <Tag color={color} style={{ margin: 2 }}>
|
|
|
|
|
+ {compact ? compactLabel : inboundLabel(id)}
|
|
|
|
|
+ </Tag>
|
|
|
|
|
+ </Tooltip>
|
|
|
);
|
|
);
|
|
|
- },
|
|
|
|
|
- }, 'inboundIds'),
|
|
|
|
|
- sortableCol({
|
|
|
|
|
- title: t('pages.clients.traffic'),
|
|
|
|
|
- key: 'traffic',
|
|
|
|
|
- render: (_v, record) => trafficLabel(record),
|
|
|
|
|
- }, 'traffic'),
|
|
|
|
|
- sortableCol({
|
|
|
|
|
- title: t('pages.clients.remaining'),
|
|
|
|
|
- key: 'remaining',
|
|
|
|
|
- width: 130,
|
|
|
|
|
- render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
|
|
|
|
|
- }, 'remaining'),
|
|
|
|
|
- sortableCol({
|
|
|
|
|
- title: t('pages.clients.duration'),
|
|
|
|
|
- key: 'expiryTime',
|
|
|
|
|
- render: (_v, record) => (
|
|
|
|
|
- <Tooltip title={expiryLabel(record)}>
|
|
|
|
|
- <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
|
|
|
|
|
- </Tooltip>
|
|
|
|
|
- ),
|
|
|
|
|
- }, 'expiryTime'),
|
|
|
|
|
- ];
|
|
|
|
|
|
|
+ };
|
|
|
|
|
+ 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
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
- }, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline, inboundsById]);
|
|
|
|
|
|
|
+ ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters]);
|
|
|
|
|
|
|
|
const tablePagination = {
|
|
const tablePagination = {
|
|
|
current: currentPage,
|
|
current: currentPage,
|
|
@@ -640,10 +697,16 @@ export default function ClientsPage() {
|
|
|
const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
|
|
const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
|
|
|
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
|
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
|
|
|
|
|
|
|
- function onToggleFilter(checked: boolean) {
|
|
|
|
|
- setEnableFilter(checked);
|
|
|
|
|
- if (checked) setSearchKey('');
|
|
|
|
|
- else setFilterBy('');
|
|
|
|
|
|
|
+ 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 (
|
|
return (
|
|
@@ -715,98 +778,172 @@ export default function ClientsPage() {
|
|
|
hoverable
|
|
hoverable
|
|
|
title={
|
|
title={
|
|
|
<div className="card-toolbar">
|
|
<div className="card-toolbar">
|
|
|
- <Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
|
|
|
|
|
|
|
+ <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
|
|
{!isMobile && t('pages.clients.addClients')}
|
|
{!isMobile && t('pages.clients.addClients')}
|
|
|
</Button>
|
|
</Button>
|
|
|
- <Button size="small" icon={<UsergroupAddOutlined />} onClick={() => setBulkAddOpen(true)}>
|
|
|
|
|
- {!isMobile && t('pages.clients.bulk')}
|
|
|
|
|
- </Button>
|
|
|
|
|
{selectedRowKeys.length > 0 && (
|
|
{selectedRowKeys.length > 0 && (
|
|
|
<>
|
|
<>
|
|
|
- <Button size="small" icon={<ClockCircleOutlined />} onClick={() => setBulkAdjustOpen(true)}>
|
|
|
|
|
|
|
+ <Button icon={<ClockCircleOutlined />} onClick={() => setBulkAdjustOpen(true)}>
|
|
|
{t('pages.clients.adjustSelected', { count: selectedRowKeys.length })}
|
|
{t('pages.clients.adjustSelected', { count: selectedRowKeys.length })}
|
|
|
</Button>
|
|
</Button>
|
|
|
- <Button danger size="small" icon={<DeleteOutlined />} onClick={onBulkDelete}>
|
|
|
|
|
|
|
+ <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 })}
|
|
{t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
|
|
|
</Button>
|
|
</Button>
|
|
|
</>
|
|
</>
|
|
|
)}
|
|
)}
|
|
|
- <Button size="small" icon={<RetweetOutlined />} onClick={onResetAllTraffics}>
|
|
|
|
|
- {!isMobile && t('pages.clients.resetAllTraffics')}
|
|
|
|
|
- </Button>
|
|
|
|
|
- <Button size="small" danger icon={<RestOutlined />} onClick={onDelDepleted}>
|
|
|
|
|
- {!isMobile && t('pages.clients.delDepleted')}
|
|
|
|
|
- </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>
|
|
|
}
|
|
}
|
|
|
>
|
|
>
|
|
|
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
|
|
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
|
|
|
- <Switch
|
|
|
|
|
- checked={enableFilter}
|
|
|
|
|
- onChange={onToggleFilter}
|
|
|
|
|
- checkedChildren={<SearchOutlined />}
|
|
|
|
|
- unCheckedChildren={<FilterOutlined />}
|
|
|
|
|
- />
|
|
|
|
|
- {!enableFilter && (
|
|
|
|
|
- <Input
|
|
|
|
|
- value={searchKey}
|
|
|
|
|
- onChange={(e) => setSearchKey(e.target.value)}
|
|
|
|
|
- placeholder={t('search')}
|
|
|
|
|
- autoFocus
|
|
|
|
|
- size={isMobile ? 'small' : 'middle'}
|
|
|
|
|
- style={{ maxWidth: 300 }}
|
|
|
|
|
- />
|
|
|
|
|
- )}
|
|
|
|
|
- {enableFilter && (
|
|
|
|
|
- <Radio.Group
|
|
|
|
|
- value={filterBy}
|
|
|
|
|
- onChange={(e) => setFilterBy(e.target.value)}
|
|
|
|
|
- optionType="button"
|
|
|
|
|
- buttonStyle="solid"
|
|
|
|
|
- size={isMobile ? 'small' : 'middle'}
|
|
|
|
|
- >
|
|
|
|
|
- <Radio.Button value="">{t('none')}</Radio.Button>
|
|
|
|
|
- <Radio.Button value="active">{t('subscription.active')}</Radio.Button>
|
|
|
|
|
- <Radio.Button value="deactive">{t('disabled')}</Radio.Button>
|
|
|
|
|
- <Radio.Button value="depleted">{t('depleted')}</Radio.Button>
|
|
|
|
|
- <Radio.Button value="expiring">{t('depletingSoon')}</Radio.Button>
|
|
|
|
|
- <Radio.Button value="online">{t('online')}</Radio.Button>
|
|
|
|
|
- </Radio.Group>
|
|
|
|
|
- )}
|
|
|
|
|
- <Select
|
|
|
|
|
- value={protocolFilter}
|
|
|
|
|
- onChange={(v) => {
|
|
|
|
|
- setProtocolFilter(v);
|
|
|
|
|
- if (v && inboundFilter) {
|
|
|
|
|
- const ib = inbounds.find((x) => x.id === inboundFilter);
|
|
|
|
|
- if (!ib || ib.protocol !== v) setInboundFilter(undefined);
|
|
|
|
|
- }
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ <Input
|
|
|
|
|
+ value={searchKey}
|
|
|
|
|
+ onChange={(e) => setSearchKey(e.target.value)}
|
|
|
|
|
+ placeholder={t('pages.clients.searchPlaceholder')}
|
|
|
allowClear
|
|
allowClear
|
|
|
- placeholder={t('pages.inbounds.protocol')}
|
|
|
|
|
|
|
+ prefix={<SearchOutlined />}
|
|
|
size={isMobile ? 'small' : 'middle'}
|
|
size={isMobile ? 'small' : 'middle'}
|
|
|
- style={{ width: 150 }}
|
|
|
|
|
- options={protocolOptions.map((p) => ({ value: p, label: p }))}
|
|
|
|
|
|
|
+ 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
|
|
<Select
|
|
|
- value={inboundFilter}
|
|
|
|
|
- onChange={(v) => setInboundFilter(v)}
|
|
|
|
|
- allowClear
|
|
|
|
|
- showSearch={{ optionFilterProp: 'label' }}
|
|
|
|
|
- placeholder={t('inbounds')}
|
|
|
|
|
|
|
+ value={sortValueFor(sortColumn, sortOrder)}
|
|
|
size={isMobile ? 'small' : 'middle'}
|
|
size={isMobile ? 'small' : 'middle'}
|
|
|
- style={{ minWidth: 160, maxWidth: 240 }}
|
|
|
|
|
- options={inbounds
|
|
|
|
|
- .filter((ib) => !protocolFilter || ib.protocol === protocolFilter)
|
|
|
|
|
- .map((ib) => ({
|
|
|
|
|
- value: ib.id,
|
|
|
|
|
- label: ib.remark
|
|
|
|
|
- ? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
|
|
|
|
|
- : `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
|
|
|
|
|
- }))}
|
|
|
|
|
|
|
+ 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>
|
|
</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 ? (
|
|
{!isMobile ? (
|
|
|
<Table<ClientRecord>
|
|
<Table<ClientRecord>
|
|
|
columns={columns}
|
|
columns={columns}
|
|
@@ -821,8 +958,8 @@ export default function ClientsPage() {
|
|
|
locale={{
|
|
locale={{
|
|
|
emptyText: (
|
|
emptyText: (
|
|
|
<div className="clients-empty">
|
|
<div className="clients-empty">
|
|
|
- <UserOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
|
|
|
|
- <div>{t('pages.clients.empty')}</div>
|
|
|
|
|
|
|
+ <TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
|
|
|
|
+ <div>{t('noData')}</div>
|
|
|
</div>
|
|
</div>
|
|
|
),
|
|
),
|
|
|
}}
|
|
}}
|
|
@@ -846,8 +983,8 @@ export default function ClientsPage() {
|
|
|
)}
|
|
)}
|
|
|
{filteredClients.length === 0 && (
|
|
{filteredClients.length === 0 && (
|
|
|
<div className="card-empty">
|
|
<div className="card-empty">
|
|
|
- <UserOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
|
|
|
|
- <div>{t('pages.clients.empty')}</div>
|
|
|
|
|
|
|
+ <TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
|
|
|
|
+ <div>{t('noData')}</div>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
{filteredClients.length > 0 && (
|
|
{filteredClients.length > 0 && (
|
|
@@ -947,6 +1084,7 @@ export default function ClientsPage() {
|
|
|
inbounds={inbounds}
|
|
inbounds={inbounds}
|
|
|
ipLimitEnable={ipLimitEnable}
|
|
ipLimitEnable={ipLimitEnable}
|
|
|
tgBotEnable={tgBotEnable}
|
|
tgBotEnable={tgBotEnable}
|
|
|
|
|
+ groups={allGroups}
|
|
|
save={onSave}
|
|
save={onSave}
|
|
|
onOpenChange={setFormOpen}
|
|
onOpenChange={setFormOpen}
|
|
|
/>
|
|
/>
|
|
@@ -974,6 +1112,7 @@ export default function ClientsPage() {
|
|
|
open={bulkAddOpen}
|
|
open={bulkAddOpen}
|
|
|
inbounds={inbounds}
|
|
inbounds={inbounds}
|
|
|
ipLimitEnable={ipLimitEnable}
|
|
ipLimitEnable={ipLimitEnable}
|
|
|
|
|
+ groups={allGroups}
|
|
|
onOpenChange={setBulkAddOpen}
|
|
onOpenChange={setBulkAddOpen}
|
|
|
onSaved={() => setBulkAddOpen(false)}
|
|
onSaved={() => setBulkAddOpen(false)}
|
|
|
/>
|
|
/>
|
|
@@ -993,7 +1132,54 @@ export default function ClientsPage() {
|
|
|
}}
|
|
}}
|
|
|
/>
|
|
/>
|
|
|
</LazyMount>
|
|
</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>
|
|
</Layout>
|
|
|
</ConfigProvider>
|
|
</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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|