Files
3x-ui/frontend/src/pages/clients/ClientsPage.tsx
T
MHSanaei 7680e27d1d feat(clients): toolbar sort selector + preserve updated_at on unchanged rows
Frontend
- New Sort dropdown in the clients toolbar covering oldest/newest,
  recently updated, recently online, email A↔Z, most traffic, highest
  remaining, expiring soonest. Default is Oldest first.
- Strip per-column sorter arrows from the Table — all sorting now flows
  through the single dropdown, so the column headers stop competing
  with it.
- Empty state: TeamOutlined icon, t('noData'), text-secondary color
  (matching the inbound/node polish).

Backend
- sortClients: add createdAt, updatedAt and lastOnline cases (with id
  tie-break for stable ordering when timestamps collide).
- Fix Recently updated: SyncInbound was calling tx.Save on every client
  in the inbound, and GORM's autoUpdateTime tag stamped updated_at to
  time.Now() each time — so editing one client bumped ALL of them.
  After the Save, restore each row's preserved updated_at via
  UpdateColumn (skips hooks). The actually-edited client gets its
  fresh stamp from the explicit UpdateColumn at the end of Update().
- Fix periodic updated_at churn: adjustTraffics unconditionally set
  c["updated_at"] = now() for every client in any inbound that had a
  delayed-start expiry, every traffic-stats pass. Turn that into a
  backfill (only when the key is missing), matching the created_at
  treatment one line above.
2026-05-27 15:07:17 +02:00

1082 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Checkbox,
Col,
ConfigProvider,
Dropdown,
Input,
Layout,
Modal,
Pagination,
Popover,
Row,
Select,
Space,
Spin,
Statistic,
Switch,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import {
ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
FilterOutlined,
InfoCircleOutlined,
MoreOutlined,
PlusOutlined,
QrcodeOutlined,
RestOutlined,
RetweetOutlined,
SearchOutlined,
SortAscendingOutlined,
TeamOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useClients } from '@/hooks/useClients';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import AppSidebar from '@/components/AppSidebar';
import { IntlUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import LazyMount from '@/components/LazyMount';
const ClientFormModal = lazy(() => import('./ClientFormModal'));
const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
const ClientQrModal = lazy(() => import('./ClientQrModal'));
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
const FilterDrawer = lazy(() => import('./FilterDrawer'));
import { emptyFilters, activeFilterCount } from './filters';
import type { ClientFilters } from './filters';
import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState';
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface PersistedFilterState {
searchKey: string;
filters: ClientFilters;
}
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
vless: 'blue',
vmess: 'geekblue',
trojan: 'volcano',
shadowsocks: 'magenta',
hysteria: 'cyan',
hysteria2: 'green',
wireguard: 'gold',
http: 'purple',
mixed: 'lime',
tunnel: 'orange',
};
const INBOUND_CHIP_LIMIT = 1;
function readFilterState(): PersistedFilterState {
try {
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
return {
searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '',
filters: {
...emptyFilters(),
...fromRaw,
buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
},
};
} catch {
return { searchKey: '', filters: emptyFilters() };
}
}
function gbToBytes(gb: number | undefined): number {
if (!gb || gb <= 0) return 0;
return Math.round(gb * 1024 * 1024 * 1024);
}
const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
{ value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' },
{ value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' },
{ value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' },
{ value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' },
{ value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' },
{ value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' },
{ value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' },
{ value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' },
{ value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' },
];
const DEFAULT_SORT = SORT_OPTIONS[0];
function sortValueFor(column: string | null, order: 'ascend' | 'descend' | null): string {
if (!column || !order) return DEFAULT_SORT.value;
return `${column}:${order}`;
}
export default function ClientsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const {
clients, filtered,
summary: serverSummary,
setQuery,
inbounds, onlines, loading, fetched, subSettings,
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, bulkDelete, bulkAdjust, attach, detach,
resetTraffic, resetAllTraffics, delDepleted, setEnable,
applyTrafficEvent, applyClientStatsEvent,
hydrate,
} = useClients();
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
});
const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
const [infoOpen, setInfoOpen] = useState(false);
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
const [qrOpen, setQrOpen] = useState(false);
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
const [bulkAddOpen, setBulkAddOpen] = useState(false);
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const initial = readFilterState();
const [searchKey, setSearchKey] = useState(initial.searchKey);
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [sortColumn, setSortColumn] = useState<string | null>(DEFAULT_SORT.column);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.order);
const [currentPage, setCurrentPage] = useState(1);
const [tablePageSize, setTablePageSize] = useState(25);
// debouncedSearch lags behind the input so we don't spam the server on every
// keystroke; the search box still feels instant locally.
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters }));
}, [searchKey, filters]);
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
return () => window.clearTimeout(handle);
}, [searchKey]);
useEffect(() => {
// Reset to page 1 whenever a filter or sort changes — otherwise an empty
// result set on a high page number leaves the user staring at "no clients".
setCurrentPage(1);
}, [debouncedSearch, filters, sortColumn, sortOrder]);
useEffect(() => {
setQuery({
page: currentPage,
pageSize: tablePageSize,
search: debouncedSearch,
filter: filters.buckets.join(','),
protocol: filters.protocols.join(','),
inbound: filters.inboundIds.join(','),
expiryFrom: filters.expiryFrom,
expiryTo: filters.expiryTo,
usageFrom: gbToBytes(filters.usageFromGB),
usageTo: gbToBytes(filters.usageToGB),
autoRenew: filters.autoRenew || undefined,
hasTgId: filters.hasTgId || undefined,
hasComment: filters.hasComment || undefined,
sort: sortColumn || undefined,
order: sortOrder || undefined,
});
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
const activeCount = activeFilterCount(filters);
useEffect(() => {
if (pageSize > 0) {
setTablePageSize(pageSize);
}
}, [pageSize]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
const inboundsById = useMemo(() => {
const out: Record<number, InboundOption> = {};
for (const ib of inbounds) out[ib.id] = ib;
return out;
}, [inbounds]);
const protocolOptions = useMemo(() => {
const values = new Set<string>((inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x));
return [...values].sort();
}, [inbounds]);
const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
function inboundLabel(id: number) {
const ib = inboundsById[id];
if (!ib) return `#${id}`;
return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
}
const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
if (!row) return null;
const traffic = row.traffic || {};
const used = (traffic.up || 0) + (traffic.down || 0);
const total = row.totalGB || 0;
const now = Date.now();
const expired = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) <= now;
const exhausted = total > 0 && used >= total;
if (expired || exhausted) return 'depleted';
if (!row.enable) return 'deactive';
const nearExpiry = (row.expiryTime ?? 0) > 0 && (row.expiryTime ?? 0) - now < (expireDiff || 0);
const nearLimit = total > 0 && total - used < (trafficDiff || 0);
if (nearExpiry || nearLimit) return 'expiring';
return 'active';
}, [expireDiff, trafficDiff]);
function bucketBadgeStatus(bucket: Bucket | null): 'success' | 'warning' | 'error' | 'default' {
switch (bucket) {
case 'depleted': return 'error';
case 'expiring': return 'warning';
case 'active': return 'success';
default: return 'default';
}
}
// The list page renders rows the server already sorted, filtered, and
// paginated. Local filtering is gone — keep the variable name so the rest
// of the file (table dataSource, mobile cards, select-all) doesn't need
// a rename.
const filteredClients = clients;
// Server-computed counts that stay stable as the user paginates/filters.
const summary = serverSummary;
// Sort is server-side now; the page already arrives in the requested
// order, so we just hand it through.
const sortedClients = filteredClients;
function trafficLabel(row: ClientRecord) {
const t0 = row.traffic;
if (!t0) return '-';
const used = (t0.up || 0) + (t0.down || 0);
const total = row.totalGB || 0;
if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
}
function remainingLabel(row: ClientRecord) {
const total = row.totalGB || 0;
if (total <= 0) return '∞';
const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
const r = total - used;
return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
}
function remainingColor(row: ClientRecord): string {
const total = row.totalGB || 0;
if (total <= 0) return 'purple';
const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
const ratio = used / total;
if (ratio >= 1) return 'red';
if (ratio >= 0.85) return 'orange';
return 'green';
}
function expiryLabel(row: ClientRecord) {
if (!row.expiryTime) return '∞';
if (row.expiryTime < 0) {
const days = Math.round(row.expiryTime / -86400000);
return `${t('pages.clients.delayedStart')}: ${days}d`;
}
return IntlUtil.formatDate(row.expiryTime, datepicker);
}
function expiryRelative(row: ClientRecord) {
if (!row.expiryTime) return '';
if (row.expiryTime < 0) {
const days = Math.round(row.expiryTime / -86400000);
return `${days}d`;
}
return IntlUtil.formatRelativeTime(row.expiryTime);
}
function expiryColor(row: ClientRecord): string {
if (!row.expiryTime) return 'purple';
if (row.expiryTime < 0) return 'blue';
const now = Date.now();
if (row.expiryTime <= now) return 'red';
if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
return 'green';
}
async function onToggleEnable(row: ClientRecord, next: boolean) {
setTogglingEmail(row.email);
try {
const msg = await setEnable(row, next);
if (!msg?.success) {
messageApi.error(msg?.msg || t('somethingWentWrong'));
}
} finally {
setTogglingEmail(null);
}
}
function onAdd() {
setFormMode('add');
setEditingClient(null);
setEditingAttachedIds([]);
setFormOpen(true);
}
async function onEdit(row: ClientRecord) {
setFormMode('edit');
// Paged list omits per-client secrets to keep the row payload tiny;
// edit needs them, so fetch the full record first.
const full = await hydrate(row.email);
const merged: ClientRecord = full ? { ...row, ...full.client } : { ...row };
setEditingClient(merged);
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
setEditingAttachedIds([...ids]);
setFormOpen(true);
}
function onDelete(row: ClientRecord) {
modal.confirm({
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
content: t('pages.clients.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(row.email);
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
},
});
}
function onResetTraffic(row: ClientRecord) {
if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
messageApi.warning(t('pages.clients.resetNotPossible'));
return;
}
modal.confirm({
title: `${t('pages.inbounds.resetTraffic')} — ${row.email}`,
content: t('pages.inbounds.resetTrafficContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetTraffic(row);
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
},
});
}
async function onShowInfo(row: ClientRecord) {
const full = await hydrate(row.email);
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setInfoOpen(true);
}
async function onShowQr(row: ClientRecord) {
const full = await hydrate(row.email);
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setQrOpen(true);
}
function onResetAllTraffics() {
modal.confirm({
title: t('pages.clients.resetAllTrafficsTitle'),
content: t('pages.clients.resetAllTrafficsContent'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetAllTraffics();
if (msg?.success) messageApi.success(t('pages.clients.toasts.allTrafficsReset'));
},
});
}
function onDelDepleted() {
modal.confirm({
title: t('pages.clients.delDepletedConfirmTitle'),
content: t('pages.clients.delDepletedConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await delDepleted();
if (msg?.success) {
const deleted = msg.obj?.deleted ?? 0;
messageApi.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
}
},
});
}
function onBulkDelete() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
content: t('pages.clients.bulkDeleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkDelete(emails);
setSelectedRowKeys([]);
const ok = msg?.obj?.deleted ?? 0;
const skipped = msg?.obj?.skipped ?? [];
const failed = skipped.length;
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
if (failed === 0 && msg?.success) {
messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
} else {
messageApi.warning(firstError
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
}
},
});
}
const onSave = useCallback(async (
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
) => {
if (!meta.isEdit) {
return create(payload);
}
const updateMsg = await update(meta.email, payload);
if (!updateMsg?.success) return updateMsg;
if (Array.isArray(meta.attach) && meta.attach.length > 0) {
const r = await attach(meta.email, meta.attach);
if (!r?.success) return r;
}
if (Array.isArray(meta.detach) && meta.detach.length > 0) {
const r = await detach(meta.email, meta.detach);
if (!r?.success) return r;
}
return updateMsg;
}, [create, update, attach, detach]);
const pageClass = useMemo(() => {
const classes = ['clients-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
if (pag?.current) setCurrentPage(pag.current);
if (pag?.pageSize) setTablePageSize(pag.pageSize);
};
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
{
title: t('pages.clients.actions'),
key: 'actions',
width: 200,
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.moreInformation')}>
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
},
{
title: t('pages.clients.enabled'),
key: 'enable',
width: 80,
render: (_v, record) => (
<Switch
checked={!!record.enable}
size="small"
loading={togglingEmail === record.email}
onChange={(next) => onToggleEnable(record, next)}
/>
),
},
{
title: t('pages.clients.online'),
key: 'online',
width: 90,
render: (_v, record) => {
const bucket = clientBucket(record);
if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
return <Tag>{t('pages.clients.offline')}</Tag>;
},
},
{
title: t('pages.clients.client'),
key: 'email',
render: (_v, record) => (
<div className="email-cell">
<span className="email">{record.email}</span>
{record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
{record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
</div>
),
},
{
title: t('pages.clients.attachedInbounds'),
key: 'inboundIds',
width: 170,
render: (_v, record) => {
const ids = record.inboundIds || [];
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
const chip = (id: number, compact: boolean) => {
const ib = inboundsById[id];
const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
return (
<Tooltip key={id} title={inboundLabel(id)}>
<Tag color={color} style={{ margin: 2 }}>
{compact ? compactLabel : inboundLabel(id)}
</Tag>
</Tooltip>
);
};
return (
<>
{visible.map((id) => chip(id, true))}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
{overflow.map((id) => chip(id, false))}
</div>
}
>
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+{overflow.length}
</Tag>
</Popover>
)}
</>
);
},
},
{
title: t('pages.clients.traffic'),
key: 'traffic',
render: (_v, record) => trafficLabel(record),
},
{
title: t('pages.clients.remaining'),
key: 'remaining',
width: 130,
render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
},
{
title: t('pages.clients.duration'),
key: 'expiryTime',
render: (_v, record) => (
<Tooltip title={expiryLabel(record)}>
<Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
</Tooltip>
),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, togglingEmail, clientBucket, isOnline, inboundsById]);
const tablePagination = {
current: currentPage,
pageSize: tablePageSize,
total: filtered,
showSizeChanger: filtered > 10,
pageSizeOptions: ['10', '25', '50', '100', '200'],
hideOnSinglePage: filtered <= tablePageSize,
showTotal: (n: number) => `${n}`,
};
const rowSelection = {
selectedRowKeys,
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as string[]),
};
function toggleSelect(email: string, checked: boolean) {
setSelectedRowKeys((prev) => {
const next = new Set(prev);
if (checked) next.add(email); else next.delete(email);
return Array.from(next);
});
}
function selectAll(checked: boolean) {
setSelectedRowKeys(checked ? filteredClients.map((c) => c.email) : []);
}
const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
if (key === 'expiryFrom' || key === 'expiryTo') {
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
return;
}
if (key === 'usageFromGB' || key === 'usageToGB') {
setFilters({ ...filters, usageFromGB: undefined, usageToGB: undefined });
return;
}
setFilters({ ...filters, [key]: emptyFilters()[key] });
}
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
<Col span={24}>
<Card size="small" hoverable className="summary-card">
<Row gutter={[16, 12]}>
<Col xs={12} sm={8} md={4}>
<Statistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('online')}
open={summary.online.length ? undefined : false}
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
>
<Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depleted')}
open={summary.depleted.length ? undefined : false}
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
>
<Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depletingSoon')}
open={summary.expiring.length ? undefined : false}
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
>
<Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('disabled')}
open={summary.deactive.length ? undefined : false}
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
>
<Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Statistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card
size="small"
hoverable
title={
<div className="card-toolbar">
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
{!isMobile && t('pages.clients.addClients')}
</Button>
<Button size="small" icon={<UsergroupAddOutlined />} onClick={() => setBulkAddOpen(true)}>
{!isMobile && t('pages.clients.bulk')}
</Button>
{selectedRowKeys.length > 0 && (
<>
<Button size="small" icon={<ClockCircleOutlined />} onClick={() => setBulkAdjustOpen(true)}>
{t('pages.clients.adjustSelected', { count: selectedRowKeys.length })}
</Button>
<Button danger size="small" icon={<DeleteOutlined />} onClick={onBulkDelete}>
{t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
</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>
</div>
}
>
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
<Input
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('pages.clients.searchPlaceholder')}
allowClear
prefix={<SearchOutlined />}
size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 320 }}
/>
<Badge count={activeCount} size="small" offset={[-4, 4]}>
<Button
icon={<FilterOutlined />}
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilterDrawerOpen(true)}
type={activeCount > 0 ? 'primary' : 'default'}
>
{!isMobile && t('filter')}
</Button>
</Badge>
<Select
value={sortValueFor(sortColumn, sortOrder)}
size={isMobile ? 'small' : 'middle'}
suffixIcon={<SortAscendingOutlined />}
style={{ minWidth: isMobile ? 130 : 200 }}
onChange={(value) => {
const opt = SORT_OPTIONS.find((o) => o.value === value);
setSortColumn(opt?.column ?? null);
setSortOrder(opt?.order ?? null);
}}
options={SORT_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
{activeCount > 0 && (
<Button
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilters(emptyFilters())}
>
{t('pages.clients.clearAllFilters')}
</Button>
)}
</div>
{activeCount > 0 && (
<div className="filter-chips">
{filters.buckets.map((b) => (
<Tag
key={`b-${b}`}
closable
onClose={() => setFilters({ ...filters, buckets: filters.buckets.filter((x) => x !== b) })}
>
{bucketChipLabel(b, t)}
</Tag>
))}
{filters.protocols.map((p) => (
<Tag
key={`p-${p}`}
closable
color="blue"
onClose={() => setFilters({ ...filters, protocols: filters.protocols.filter((x) => x !== p) })}
>
{p}
</Tag>
))}
{filters.inboundIds.map((id) => (
<Tag
key={`i-${id}`}
closable
color="cyan"
onClose={() => setFilters({ ...filters, inboundIds: filters.inboundIds.filter((x) => x !== id) })}
>
{inboundLabel(id)}
</Tag>
))}
{(filters.expiryFrom || filters.expiryTo) && (
<Tag closable color="purple" onClose={() => clearOneFilter('expiryFrom')}>
{t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'}
{' → '}
{filters.expiryTo ? IntlUtil.formatDate(filters.expiryTo, datepicker) : '…'}
</Tag>
)}
{(filters.usageFromGB || filters.usageToGB) && (
<Tag closable color="orange" onClose={() => clearOneFilter('usageFromGB')}>
{t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}{filters.usageToGB ? `–${filters.usageToGB}` : '+'} GB
</Tag>
)}
{filters.autoRenew && (
<Tag closable color="gold" onClose={() => clearOneFilter('autoRenew')}>
{t('pages.clients.renew')}: {filters.autoRenew === 'on' ? t('enabled') : t('disabled')}
</Tag>
)}
{filters.hasTgId && (
<Tag closable onClose={() => clearOneFilter('hasTgId')}>
{t('pages.clients.telegramId')}: {filters.hasTgId === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
</Tag>
)}
{filters.hasComment && (
<Tag closable onClose={() => clearOneFilter('hasComment')}>
{t('pages.clients.comment')}: {filters.hasComment === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
</Tag>
)}
</div>
)}
{!isMobile ? (
<Table<ClientRecord>
columns={columns}
dataSource={sortedClients}
loading={loading}
rowKey="email"
rowSelection={rowSelection}
pagination={tablePagination}
size="small"
scroll={{ x: 1200 }}
onChange={onTableChange}
locale={{
emptyText: (
<div className="clients-empty">
<TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
/>
) : (
<Spin spinning={loading}>
<div className="client-cards">
{filteredClients.length > 0 && (
<div className="card-bulk-bar">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={(e) => selectAll(e.target.checked)}
>
{t('pages.clients.selectAll')}
</Checkbox>
{selectedRowKeys.length > 0 && (
<span className="bulk-count">{selectedRowKeys.length}</span>
)}
</div>
)}
{filteredClients.length === 0 && (
<div className="card-empty">
<TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div>
</div>
)}
{filteredClients.length > 0 && (
<div className="card-pagination">
<Pagination
current={currentPage}
pageSize={tablePageSize}
total={filtered}
showSizeChanger={filtered > 10}
pageSizeOptions={['10', '25', '50', '100', '200']}
hideOnSinglePage={filtered <= tablePageSize}
size="small"
showTotal={(n) => `${n}`}
onChange={(p, s) => {
setCurrentPage(p);
if (s && s !== tablePageSize) setTablePageSize(s);
}}
/>
</div>
)}
{filteredClients.map((row) => {
const bucket = clientBucket(row);
return (
<div key={row.email} className={`client-card${selectedRowKeys.includes(row.email) ? ' is-selected' : ''}`}>
<div className="card-head">
<Checkbox
checked={selectedRowKeys.includes(row.email)}
onChange={(e) => toggleSelect(row.email, e.target.checked)}
/>
<Badge status={bucketBadgeStatus(bucket)} />
<span className="tag-name">{row.email}</span>
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('pages.clients.moreInformation')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
</Tooltip>
<Switch
checked={!!row.enable}
size="small"
loading={togglingEmail === row.email}
onChange={(next) => onToggleEnable(row, next)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: [
{
key: 'qr',
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
onClick: () => onShowQr(row),
},
{
key: 'reset',
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
onClick: () => onResetTraffic(row),
},
{
key: 'edit',
label: <><EditOutlined /> {t('edit')}</>,
onClick: () => onEdit(row),
},
{
key: 'delete',
danger: true,
label: <><DeleteOutlined /> {t('delete')}</>,
onClick: () => onDelete(row),
},
],
}}
>
<MoreOutlined className="row-action-trigger" />
</Dropdown>
</div>
</div>
</div>
);
})}
</div>
</Spin>
)}
</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<LazyMount when={formOpen}>
<ClientFormModal
open={formOpen}
mode={formMode}
client={editingClient}
attachedIds={editingAttachedIds}
inbounds={inbounds}
ipLimitEnable={ipLimitEnable}
tgBotEnable={tgBotEnable}
save={onSave}
onOpenChange={setFormOpen}
/>
</LazyMount>
<LazyMount when={infoOpen}>
<ClientInfoModal
open={infoOpen}
client={infoClient}
inboundsById={inboundsById}
isOnline={infoClient ? isOnline(infoClient.email) : false}
subSettings={subSettings}
onOpenChange={setInfoOpen}
/>
</LazyMount>
<LazyMount when={qrOpen}>
<ClientQrModal
open={qrOpen}
client={qrClient}
subSettings={subSettings}
onOpenChange={setQrOpen}
/>
</LazyMount>
<LazyMount when={bulkAddOpen}>
<ClientBulkAddModal
open={bulkAddOpen}
inbounds={inbounds}
ipLimitEnable={ipLimitEnable}
onOpenChange={setBulkAddOpen}
onSaved={() => setBulkAddOpen(false)}
/>
</LazyMount>
<LazyMount when={bulkAdjustOpen}>
<ClientBulkAdjustModal
open={bulkAdjustOpen}
count={selectedRowKeys.length}
onOpenChange={setBulkAdjustOpen}
onSubmit={async (addDays, addBytes) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
if (msg?.success) {
setSelectedRowKeys([]);
return msg.obj ?? { adjusted: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={filterDrawerOpen}>
<FilterDrawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
filters={filters}
onChange={setFilters}
inbounds={inbounds}
protocols={protocolOptions}
/>
</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;
}
}