3x-ui/frontend/src/pages/clients/ClientsPage.tsx
Sanaei cfe1b25ca0
feat(frontend): TanStack Query + React Router migration & in-panel API docs (#4541)
* feat(frontend): introduce TanStack Query with status polling

Wires @tanstack/react-query into every entry and migrates useStatus to
useStatusQuery as the foundation for the multi-page MPA → SPA migration.

- QueryProvider wraps each entry inside ThemeProvider, with devtools gated
  on import.meta.env.DEV
- Shared queryClient: 30s staleTime, refetchOnWindowFocus, 1 retry
- useStatusQuery preserves the { status, fetched, refresh } shape so
  IndexPage swaps in without further changes
- refetchIntervalInBackground:false stops the 2s status poll when the
  panel tab is hidden, cutting idle traffic against the server

* feat(frontend): collapse panel pages into a single React Router SPA

Replaces the 7-entry MPA shell (index/clients/inbounds/nodes/settings/
xray/api-docs HTML files) with one main.tsx + createBrowserRouter. The
Go backend now serves the same index.html for every authenticated
panel route; React Router reads the URL and mounts the page from cache
on subsequent navigation — no more full reloads between tabs.

Frontend
- main.tsx: single bootstrap (setupAxios, i18n, ThemeProvider,
  QueryProvider, RouterProvider) replacing 7 near-duplicate entries
- routes.tsx: declarative router with lazy()-loaded pages, basename
  derived from window.X_UI_BASE_PATH so panels at /secret/panel work
- layouts/PanelLayout.tsx: shell mount-point for the WS → queryClient
  bridge so connection survives navigation
- api/websocketBridge.ts: subscribes the singleton WebSocketClient to
  queryClient and dispatches invalidate/outbounds events to cached
  queries (page-level useWebSocket handlers stay until Phase 3 hooks
  migrate)
- AppSidebar: navigates via useNavigate + useLocation instead of
  window.location.href; drops basePath/requestUri props
- Pages: drop the unused basePath/requestUri locals exposed only for
  the old sidebar

Build
- vite.config: 9 rollup inputs → 3 (index, login, subpage). Dev proxy
  bypass collapses /panel/* to index.html and skips API prefixes
- vendor-tanstack + vendor-router chunks added to manualChunks

Backend
- xui.go: 7 per-page HTML handlers → one panelSPA handler serving
  index.html for /, /inbounds, /clients, /nodes, /settings, /xray,
  /api-docs. The /panel/api, /panel/setting, /panel/xray sub-routers
  are untouched

* feat(frontend): migrate useNodes to TanStack Query

Splits the hand-rolled useNodes hook into useNodesQuery (server data +
NodeRecord type + derived totals) and useNodeMutations (add/update/del/
setEnable/probe/test). Mutations invalidate ['nodes'] on success, so
the list refreshes without each call awaiting a manual refresh().

NodesPage drops useWebSocket({ nodes: applyNodesEvent }) — the
WebSocket → query bridge now forwards the 'nodes' push to
setQueryData(['nodes', 'list']) once at the SPA root.

InboundsPage and the inbound form/list components import NodeRecord
from its new home next to the query hook.

* feat(frontend): migrate useAllSetting to TanStack Query

Replaces the hand-rolled fetch + dirty-tracking hook with useAllSettings
backed by useQuery + useMutation. The draft (current edits) is kept in
local state and reset whenever query.data lands. saveAll posts the
draft via a mutation; on success, invalidating ['settings'] refetches
and the useEffect resets the draft so saveDisabled flips back to true.

staleTime: Infinity prevents refetchOnWindowFocus from clobbering
in-flight edits — settings only change in response to this user's own
save.

setSpinning stays as a pass-through to a local flag so the existing
restartPanel flow in SettingsPage keeps showing its spinner.

* feat(frontend): route useInbounds fetches through TanStack Query

Rewrites useInbounds so its four server fetches (slim list, default
settings, online clients, last-online map) live in useQuery with
staleTime: Infinity. The in-place WS merge logic for traffic and
client_stats is preserved — applyTrafficEvent / applyClientStatsEvent
still mutate the locally-mirrored dbInbounds so the panel doesn't
refetch every 1-2 seconds when stats stream in.

refresh() becomes a thin invalidateQueries on the three list keys,
which mutations in the page already call after add/edit/del.

The bridge now forwards the WebSocket 'inbounds' push to
setQueryData(['inbounds', 'slim']), and InboundsPage drops its
useEffect(fetchDefaultSettings → refresh) plus the invalidate /
inbounds wiring on useWebSocket — both are owned by the bridge now.

* feat(frontend): migrate useClients to TanStack Query

Replaces 12 hand-rolled mutation callbacks and a tangle of useState +
useRef + useEffect with one useQuery (paged list) + nine useMutation
wrappers. The list query uses keepPreviousData so paging/filter
changes don't blank the table mid-fetch.

The setQuery shallow-compare logic is preserved for backward
compatibility with ClientsPage's effect that rebuilds the params on
every render. Internally setQuery only updates state when the params
actually differ — Query's queryKey equality handles the rest.

WS-driven applyTrafficEvent / applyClientStatsEvent now mutate the
query cache via setQueryData(['clients', 'list', currentParams]) so
per-second stats updates skip a full refetch. applyInvalidate is gone
from the hook — the bridge owns coarse 'clients' invalidation.

ClientsPage drops the invalidate handler from its useWebSocket
subscription; auxiliary queries (inboundOptions, defaults, onlines)
load via TanStack Query and are shared with useInbounds via the same
query keys.

* feat(frontend): route useXraySetting fetches through TanStack Query

Keeps the bidirectional xraySetting ↔ templateSettings editor sync and
the 1s dirty-tracking interval intact (those are local editor state,
not server data). All seven server calls move:

- config + traffic → useQuery on ['xray', 'config'] and
  ['xray', 'outboundsTraffic']
- saveAll → useMutation that invalidates the config query
- resetOutboundsTraffic → useMutation that invalidates the traffic
  query
- restartXray → useMutation (fires the restart, then reads the
  result string)
- resetToDefault → useMutation (fetch default config, push it into
  the editor via setTemplateSettings)

The WebSocket 'outbounds' event already lands in
keys.xray.outboundsTraffic() via the bridge, so XrayPage drops its
useWebSocket({ outbounds: applyOutboundsEvent }) wiring entirely and
the hook no longer exposes applyOutboundsEvent.

A useEffect seeds xraySetting / templateSettings / tags / test URL
from query data on first fetch and on every refetch, mirroring what
the original fetchAll() did.

* fix(frontend): restore per-route document titles in the SPA

When the multi-entry MPA collapsed into a single index.html, every
route inherited the static <title>3X-UI</title> from the shared shell,
so every panel page showed "hostname - 3X-UI" instead of the original
"hostname - Overview / Clients / Inbounds / ...".

usePageTitle reads the current pathname and rewrites document.title
on every navigation, matching the titles the deleted *.html files
used to carry. Mounted in PanelLayout so it covers all panel routes
without each page having to opt in.

The startup applyDocumentTitle() call in main.tsx is gone — the hook
sets the full "hostname - PageTitle" string itself.

* feat(api-docs): expose OpenAPI spec + render Swagger UI in panel

Replaces the hand-rolled API docs UI with industry-standard tooling so
external integrations (Postman, Insomnia, openapi-generator) can
consume the panel API without parsing endpoints.js by hand.

Generator
- frontend/scripts/build-openapi.mjs: walks the existing endpoints.js
  (still the single source of truth) and emits an OpenAPI 3.0.3 spec
  at frontend/public/openapi.json. Handles Gin :param → {param} path
  translation, body / query / path parameter splits, 200 + error
  response examples, and Bearer + cookie security schemes
- npm run build now runs gen:api before vite build, so the spec is
  always in sync with what's documented

Backend
- web/controller/dist.go exposes ServeOpenAPISpec which streams the
  embedded dist/openapi.json with a short Cache-Control. Public
  endpoint (no auth) so Postman can fetch it without first logging in
- web/web.go wires GET /panel/api/openapi.json before the auth-gated
  /panel/api router

Panel
- ApiDocsPage now renders swagger-ui-react fed by the basePath-aware
  openapi.json URL. Dark mode is overridden via CSS targeting the
  Swagger UI internals
- CodeBlock / EndpointRow / EndpointSection are gone; the swagger-ui
  vendor chunk (134 KB gzipped) only loads on this lazy route, not on
  every panel page
- vite.config: vendor-swagger manualChunk keeps the new dep out of
  the main vendor bundle

For Postman: import http://<panel>/panel/api/openapi.json. Everything
from /login + /panel/api/* shows up with auth, params, and examples.

* style(api-docs): dark/ultra theme for Swagger UI

Override every visual surface Swagger does not theme on its own:
opblocks, tables, model boxes, form inputs, code blocks, modals,
Servers dropdown, per-endpoint padlocks and expand chevrons. Replaces
Swagger's default light-arrow chevron on selects with a light-fill SVG
positioned at the corner so the dark background-color is visible.

Also disables deepLinking to silence the noisy v4 underscore warning;
not used in our panel.
2026-05-24 21:34:52 +02:00

960 lines
39 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,
Radio,
Row,
Select,
Space,
Spin,
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,
TeamOutlined,
UserOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useClients } from '@/hooks/useClients';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic';
import { IntlUtil, 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'));
import '@/styles/page-cards.css';
import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState';
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface FilterState {
enableFilter: boolean;
searchKey: string;
filterBy: string;
protocolFilter?: string;
inboundFilter?: number;
}
function readFilterState(): FilterState {
try {
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
return {
enableFilter: !!raw.enableFilter,
searchKey: raw.searchKey || '',
filterBy: raw.filterBy || '',
protocolFilter: raw.protocolFilter,
inboundFilter: inb,
};
} catch {
return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
}
}
export default function ClientsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const {
clients, filtered,
summary: serverSummary,
setQuery,
inbounds, onlines, loading, fetched, subSettings,
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, removeMany, 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 [enableFilter, setEnableFilter] = useState(initial.enableFilter);
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 [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
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({
enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
}));
}, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
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, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
useEffect(() => {
setQuery({
page: currentPage,
pageSize: tablePageSize,
search: enableFilter ? '' : debouncedSearch,
filter: enableFilter ? (filterBy || '') : '',
protocol: protocolFilter || '',
inbound: inboundFilter,
sort: sortColumn || undefined,
order: sortOrder || undefined,
});
}, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
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 bucketBadgeColor(bucket: Bucket | null): string {
switch (bucket) {
case 'depleted': return '#ff4d4f';
case 'expiring': return '#fa8c16';
case 'deactive': return 'rgba(128,128,128,0.6)';
case 'active': return '#52c41a';
default: return 'rgba(128,128,128,0.6)';
}
}
// 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 results = await removeMany(emails);
setSelectedRowKeys([]);
let ok = 0;
let failed = 0;
let firstError = '';
for (const msg of results) {
if (msg?.success) ok++;
else {
failed++;
if (!firstError && msg?.msg) firstError = msg.msg;
}
}
if (failed === 0) {
messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
} else {
messageApi.warning(firstError
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
}
},
});
}
const onSave = useCallback(async (
payload: Record<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, _filters, sorter) => {
if (pag?.current) setCurrentPage(pag.current);
if (pag?.pageSize) setTablePageSize(pag.pageSize);
const s = Array.isArray(sorter) ? sorter[0] : sorter;
setSortColumn((s?.columnKey as string) || (s?.field as string) || null);
setSortOrder((s?.order as 'ascend' | 'descend' | null) || null);
};
const columns = useMemo<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>
),
},
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>;
},
},
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>}
</div>
),
}, 'email'),
sortableCol({
title: t('pages.clients.attachedInbounds'),
key: 'inboundIds',
render: (_v, record) => {
const ids = record.inboundIds || [];
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}></span>;
return ids.map((id) => (
<Tag key={id} color="blue" style={{ margin: 2 }}>{inboundLabel(id)}</Tag>
));
},
}, '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'),
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, togglingEmail, sortColumn, sortOrder, 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 onToggleFilter(checked: boolean) {
setEnableFilter(checked);
if (checked) setSearchKey('');
else setFilterBy('');
}
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}>
<CustomStatistic 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>}
>
<CustomStatistic 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>}
>
<CustomStatistic 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>}
>
<CustomStatistic 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>}
>
<CustomStatistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<CustomStatistic 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'}>
<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);
}
}}
allowClear
placeholder={t('pages.inbounds.protocol')}
size={isMobile ? 'small' : 'middle'}
style={{ width: 150 }}
options={protocolOptions.map((p) => ({ value: p, label: p }))}
/>
<Select
value={inboundFilter}
onChange={(v) => setInboundFilter(v)}
allowClear
showSearch
optionFilterProp="label"
placeholder={t('inbounds')}
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}` : ''}`,
}))}
/>
</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">
<UserOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('pages.clients.empty')}</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">
<UserOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('pages.clients.empty')}</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 color={bucketBadgeColor(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>
</Layout>
</ConfigProvider>
);
}