mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
* refactor(frontend): reorganize components & pages into feature folders
No behavior change; pure file relocation + import path updates.
* refactor(frontend): move shared protocol enums to schemas/protocols/shared
Decouple Outbound from Inbound schemas: SSMethodSchema and VmessSecuritySchema (shared between inbound & outbound) now live in a neutral schemas/protocols/shared/ module. Outbound no longer reaches into schemas/protocols/inbound/*. Pure relocation + import rewiring; schema values identical, snapshots & golden tests unchanged.
* refactor(frontend): break InboundList into helpers/types/RowActions/columns hook/stats modal
InboundList.tsx 781 -> 203 lines. Extracted pure helpers (network labels, sort fns, isInboundMultiUser), shared types, the row-actions menu/cell, the table columns hook, and the mobile stats modal into the list/ folder. Code moved verbatim; no behavior change. typecheck/lint/test/build green, 337 tests pass.
* refactor(frontend): extract InboundInfoModal helpers, types & buildInboundInfo
InboundInfoModal.tsx 1081 -> 836 lines. Moved the pure data helpers (network host/path readers, link-protocol check, copy/download/statsColor/IP formatting) plus all shared types and the buildInboundInfo data builder into info/helpers.ts and info/types.ts. The state-coupled render body is left intact (no React render tests to guard a deeper split). Code moved verbatim; no behavior change. All gates green, 337 tests pass.
* test(frontend): add React Testing Library + jsdom render-test harness
- vitest projects: node unit tests stay lean; new jsdom 'components' project runs *.test.tsx
- component setup: matchMedia/ResizeObserver/localStorage polyfills, react-i18next init, persian-calendar-suite stub (only used under jalali locale)
- smoke + field-label structure snapshots for Inbound & Outbound form modals
- establishes the regression net required before decomposing the oversized form modals
- 341 tests pass (337 unit + 4 component); typecheck/lint/build green
* test(frontend): per-protocol field-structure coverage for both form modals
- drive the protocol Select in jsdom and snapshot rendered Form.Item labels for every protocol
- 10 outbound + 10 inbound protocol states captured as the regression net for protocol-core extraction
- add robust select-driving helpers (test-utils) + post-test body cleanup (setup.components)
- 341 tests pass; typecheck/lint green
* refactor(frontend): extract OutboundFormModal constants & stream helpers
OutboundFormModal.tsx 2238 -> 2080. Moved the pure option arrays/sets and the stream-slice helpers (newStreamSlice, hysteriaStreamSlice, isMuxAllowed, buildAddModeValues) into outbound-form-constants.ts and outbound-form-helpers.ts. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.
* refactor(frontend): extract InboundFormModal advanced JSON editors
InboundFormModal.tsx 3129 -> 2863. Moved AdvancedSliceEditor and AdvancedAllEditor (the in-modal JSON slice/all editors) into advanced-editors.tsx along with their adapter-helper imports. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal loopback/blackhole/dns field blocks
Moved the outbound-only protocol field blocks (loopback, blackhole, dns) out of the modal render body into outbound-only-fields.tsx. First render-body extraction behind the per-protocol snapshot net: loopback/blackhole/dns snapshots unchanged -> verified no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal freedom field block
OutboundFormModal.tsx 2063 -> 1753. Moved the freedom protocol field block (domainStrategy, fragment, noises, finalRules) into outbound-freedom-fields.tsx. Verbatim relocation; freedom per-protocol snapshot unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal wireguard field block
OutboundFormModal.tsx 1753 -> 1622. Moved the wireguard protocol field block (address, keypair gen, domainStrategy, peers + allowedIPs) into outbound-wireguard-fields.tsx; dropped now-unused icon/InputAddon/WireguardDomainStrategy imports. Verbatim relocation; wireguard snapshot unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal core protocol fields
OutboundFormModal.tsx 1622 -> 1538. Moved the shared protocol core field blocks (vmess/vless ID, vmess security, vless encryption/reverseTag, trojan/ss password, ss method/uot, socks/http user/pass) into outbound-core-fields.tsx; dropped now-unused schema/option imports. Per-protocol snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): fold OutboundFormModal server address/port block into core fields
OutboundFormModal.tsx 1538 -> 1516. Moved the shared connect-target (address/port) block into OutboundCoreProtocolFields at the same render position; dropped the now-unused SERVER_PROTOCOLS import. Snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split outbound-only protocol forms into per-protocol files
Replace the grouped outbound-only-fields.tsx + outbound-freedom-fields.tsx with one file per protocol under outbounds/protocols/: freedom.tsx, blackhole.tsx, dns.tsx, loopback.tsx (+ barrel). Matches the prompt's 1-file-per-protocol structure. Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split outbound protocol forms into per-protocol files
Replace the grouped outbound-core-fields / outbound-wireguard-fields with one file per protocol under outbounds/protocols/: vmess, vless, trojan, shadowsocks, http, socks, wireguard, freedom, blackhole, dns, loopback (+ shared server-target). Matches the prompt's 1-file-per-protocol structure (per-modal). Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split outbound transport forms into per-transport files
Extract the tcp(raw)/kcp/ws/grpc/httpupgrade transport blocks into outbounds/transport/ per-file components (RawForm, KcpForm, WsForm, GrpcForm, HttpUpgradeForm). xhttp + hysteria transport remain inline for a follow-up. Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal xhttp transport form
Move the xhttp transport block into transport/xhttp.tsx (takes form + onXmuxToggle prop); drop now-unused HeaderMapEditor and MODE_OPTIONS imports from the modal. OutboundFormModal.tsx down to ~1001 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal tls/reality security forms
Move the TLS and Reality field blocks into outbounds/security/{tls,reality}.tsx; the none/TLS/Reality Radio.Group selector stays in the modal. Drop now-unused ALPN_OPTIONS/UTLS_OPTIONS imports. OutboundFormModal.tsx down to ~918 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split inbound-only protocol forms (tun, tunnel) into per-file
Extract the tun and tunnel protocol blocks from InboundFormModal into inbounds/form/protocols/{tun,tunnel}.tsx (presentational, declarative). First inbound-side per-protocol split. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split inbound wireguard & shadowsocks protocol forms
Extract the wireguard and shadowsocks protocol blocks from InboundFormModal into inbounds/form/protocols/{wireguard,shadowsocks}.tsx (presentational; form + regen handlers / isSSWith2022 passed as props). Drop now-unused Divider + SSMethodSchema imports. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split inbound vless/http/mixed/hysteria protocol forms
Extract the remaining inbound protocol blocks into inbounds/form/protocols/: vless (auth handlers/state as props), http + mixed (shared accounts-list), hysteria. Drop now-unused HysteriaMasqueradeForm/Typography/Text imports from the modal. InboundFormModal.tsx 2841 -> 2478. Inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): move HysteriaMasqueradeForm to lib/xray/forms/transport
The hysteria masquerade form edits streamSettings.hysteriaSettings.masquerade (a transport/stream concept) and is rendered identically by both modals, so it belongs next to FinalMaskForm in lib/xray/forms/transport/ rather than protocols/shared/. Moved the file, updated the transport barrel + both consumers (inbound hysteria protocol form, outbound modal), and removed the now-empty protocols/shared/ folder. Pure relocation; snapshots unchanged, typecheck/lint/build green.
* refactor(frontend): extract inbound transport forms into transport/ folder
Move the six inbound stream-transport blocks (tcp/raw, ws, grpc, xhttp,
httpupgrade, kcp) out of InboundFormModal into presentational components
under inbounds/form/transport/. XhttpForm takes the form instance and
re-derives its mode/obfs/placement watches internally; the rest are
declarative. InboundFormModal drops from 2566 to 2105 lines. No behavior
change — per-protocol field-label snapshots unchanged.
* refactor(frontend): extract inbound security forms into security/ folder
Move the inbound TLS and Reality stream-security blocks out of
InboundFormModal into presentational components under
inbounds/form/security/. The Radio.Group security selector stays in the
modal; TlsForm and RealityForm receive their cert/key/ECH generation
handlers and the saving flag as props. InboundFormModal drops from 2105
to 1708 lines.
Add inbound-form-blocks.test.tsx: render-snapshot coverage for each
extracted transport (raw/ws/grpc/kcp/httpupgrade/xhttp) and security
(tls/reality) component in isolation inside a minimal Form. The full
modal cannot exercise the stream/security tabs in jsdom because they are
gated behind Form.useWatch values that do not propagate in the test
harness, so component-level snapshots are the regression net for these
blocks. No behavior change.
* refactor(frontend): extract outbound sockopt/mux/hysteria transport blocks
Move the last three oversized inline stream blocks out of
OutboundFormModal into presentational components under
xray/outbounds/transport/: SockoptForm (~260 lines, the worst offender),
MuxForm, and HysteriaForm. Each takes the form instance; MuxForm also
takes protocol/network and keeps its isMuxAllowed gate. OutboundFormModal
drops from 962 to 621 lines and no inline section now exceeds the
250-line guideline. Existing outbound-form-modal snapshots already cover
sockopt/mux and stay byte-identical, confirming no behavior change.
* refactor(frontend): extract inbound sockopt + external-proxy blocks
Move the inbound Sockopt (~250 lines) and External Proxy stream blocks
out of InboundFormModal into presentational components under
inbounds/form/transport/, mirroring the outbound extraction. Each takes
its toggle handler (toggleSockopt / toggleExternalProxy) as a prop and
keeps its render-prop getFieldValue gate. InboundFormModal drops from
1708 to 1332 lines.
Extend inbound-form-blocks.test.tsx with isolated render-snapshot
coverage for both (SockoptForm seeded enabled + happyEyeballs;
ExternalProxyForm seeded with one TLS entry). No behavior change.
* refactor(frontend): break down RoutingTab into sections
Extract RoutingTab's presentational pieces into the routing/ folder:
helpers.ts (arrJoin/csv/chipPreview/ruleCriteriaChips), types.ts
(RuleRow), CriterionRow.tsx, RuleCardList.tsx (mobile card view), and
useRoutingColumns.tsx (desktop table columns hook). RoutingTab stays the
orchestrator holding rule state, mutate, tag-option memos and the
pointer-drag reorder logic, and drops from 550 to 291 lines. No behavior
change.
* refactor(frontend): extract BasicsTab constants and rule helpers
Move BasicsTab's geo option arrays + freedom/ipv4 outbound presets into
basics/constants.ts and the routing-rule get/set/sync helpers into
basics/helpers.ts. BasicsTab drops from 550 to 447 lines and keeps its
Collapse-of-settings panels (which stay coupled to mutate + derived
state, so splitting them into components would only add prop-drilling).
No behavior change.
* refactor(frontend): break down DnsTab columns/helpers/types
Extract DnsTab's pure pieces into the dns/ folder: helpers.ts
(STRATEGIES/DEFAULT_FAKEDNS + addr/domains/expectedIPs accessors),
types.ts (DnsConfig/HostRow/FakednsRow), and useDnsColumns.tsx
(useDnsServerColumns + useFakednsColumns table-column hooks taking their
row handlers as params). DnsTab stays the orchestrator for dns state,
mutate, hosts sync and the Collapse panels, and drops from 539 to 424
lines. No behavior change.
* refactor(frontend): break down OutboundsTab into sections
Extract OutboundsTab's pieces: outbounds-tab-types.ts (OutboundRow),
outbounds-tab-helpers.ts (address/untestable/security/breakdown +
traffic/testing/result accessors), useOutboundColumns.tsx (desktop table
columns hook) and OutboundCardList.tsx (mobile card view). OutboundsTab
stays the orchestrator for outbound state, mutate, reorder and the
toolbar, and drops from 516 to 238 lines. No behavior change.
This completes plan section 2.4.5 — all four oversized Xray tabs
(Basics/Routing/Dns/Outbounds) are now broken into sections + hooks.
* refactor(frontend): fold HysteriaMasqueradeForm into the hysteria forms
Inline the masquerade fields directly into both hysteria transport forms
(inbounds/form/protocols/hysteria + xray/outbounds/transport/hysteria)
and delete the shared lib/xray/forms/transport/HysteriaMasqueradeForm so
each hysteria form is self-contained. The masquerade JSX is unchanged;
form is typed as the untyped FormInstance (as the shared component was)
so the masquerade name paths still resolve. No behavior change.
* refactor(frontend): slim InboundFormModal by extracting hooks + sections
Pull the modal's non-layout logic into focused files at the form root:
- useSecurityActions.ts: TLS/Reality key + cert generation handlers and
onSecurityChange (consumed by the security tab)
- useInboundFallbacks.ts: fallback row state + load/save/derive/add/
update/remove/move handlers + eligible-child options
- FallbacksCard.tsx: the fallbacks card UI (presentational)
- SniffingTab.tsx: the sniffing tab UI (presentational)
Also drop the stale "Pattern A rewrite / sibling file" header comment and
the imports the extractions made unused. InboundFormModal goes from 1332
to 868 lines with no behavior change (351 tests green, snapshots
unchanged).
1315 lines
53 KiB
TypeScript
1315 lines
53 KiB
TypeScript
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Checkbox,
|
||
Col,
|
||
ConfigProvider,
|
||
Dropdown,
|
||
Input,
|
||
Layout,
|
||
Modal,
|
||
Pagination,
|
||
Popover,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Spin,
|
||
Statistic,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType, TableProps } from 'antd/es/table';
|
||
import {
|
||
ClockCircleOutlined,
|
||
DeleteOutlined,
|
||
EditOutlined,
|
||
FilterOutlined,
|
||
InfoCircleOutlined,
|
||
LinkOutlined,
|
||
MoreOutlined,
|
||
PlusOutlined,
|
||
QrcodeOutlined,
|
||
RestOutlined,
|
||
RetweetOutlined,
|
||
SearchOutlined,
|
||
SortAscendingOutlined,
|
||
TagsOutlined,
|
||
TeamOutlined,
|
||
UsergroupAddOutlined,
|
||
UsergroupDeleteOutlined,
|
||
} 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 '@/layouts/AppSidebar';
|
||
import { IntlUtil, SizeFormatter } from '@/utils';
|
||
import { setMessageInstance } from '@/utils/messageBus';
|
||
import { LazyMount } from '@/components/utility';
|
||
const ClientFormModal = lazy(() => import('./ClientFormModal'));
|
||
const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
|
||
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
||
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
||
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
||
const FilterDrawer = lazy(() => import('./FilterDrawer'));
|
||
const SubLinksModal = lazy(() => import('./SubLinksModal'));
|
||
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
|
||
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
||
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
||
import { emptyFilters, activeFilterCount } from './filters';
|
||
import type { ClientFilters } from './filters';
|
||
import './ClientsPage.css';
|
||
|
||
const FILTER_STATE_KEY = 'clientsFilterState';
|
||
|
||
function UngroupIcon() {
|
||
return (
|
||
<span
|
||
style={{
|
||
position: 'relative',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
width: '1em',
|
||
height: '1em',
|
||
}}
|
||
>
|
||
<TagsOutlined />
|
||
<span
|
||
aria-hidden="true"
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
display: 'block',
|
||
width: '125%',
|
||
height: '1.5px',
|
||
background: 'currentColor',
|
||
transform: 'rotate(-45deg)',
|
||
borderRadius: '1px',
|
||
}}
|
||
/>
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||
|
||
interface PersistedFilterState {
|
||
searchKey: string;
|
||
filters: ClientFilters;
|
||
}
|
||
|
||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||
vless: 'blue',
|
||
vmess: 'geekblue',
|
||
trojan: 'volcano',
|
||
shadowsocks: 'magenta',
|
||
hysteria: 'cyan',
|
||
hysteria2: 'green',
|
||
wireguard: 'gold',
|
||
http: 'purple',
|
||
mixed: 'lime',
|
||
tunnel: 'orange',
|
||
};
|
||
const INBOUND_CHIP_LIMIT = 1;
|
||
|
||
function readFilterState(): PersistedFilterState {
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
||
const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
|
||
return {
|
||
searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '',
|
||
filters: {
|
||
...emptyFilters(),
|
||
...fromRaw,
|
||
buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
|
||
protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
|
||
inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
|
||
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
|
||
},
|
||
};
|
||
} catch {
|
||
return { searchKey: '', filters: emptyFilters() };
|
||
}
|
||
}
|
||
|
||
function gbToBytes(gb: number | undefined): number {
|
||
if (!gb || gb <= 0) return 0;
|
||
return Math.round(gb * 1024 * 1024 * 1024);
|
||
}
|
||
|
||
const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
|
||
{ value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' },
|
||
{ value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' },
|
||
{ value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' },
|
||
{ value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' },
|
||
{ value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' },
|
||
{ value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' },
|
||
{ value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' },
|
||
{ value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' },
|
||
{ value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' },
|
||
];
|
||
|
||
const DEFAULT_SORT = SORT_OPTIONS[0];
|
||
|
||
function sortValueFor(column: string | null, order: 'ascend' | 'descend' | null): string {
|
||
if (!column || !order) return DEFAULT_SORT.value;
|
||
return `${column}:${order}`;
|
||
}
|
||
|
||
export default function ClientsPage() {
|
||
const { t } = useTranslation();
|
||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||
const { datepicker } = useDatepicker();
|
||
const { isMobile } = useMediaQuery();
|
||
const [modal, modalContextHolder] = Modal.useModal();
|
||
const [messageApi, messageContextHolder] = message.useMessage();
|
||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||
|
||
const {
|
||
clients, filtered,
|
||
summary: serverSummary,
|
||
allGroups,
|
||
setQuery,
|
||
inbounds, onlines, loading, fetched, subSettings,
|
||
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
|
||
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
||
applyTrafficEvent, applyClientStatsEvent,
|
||
hydrate,
|
||
} = useClients();
|
||
|
||
useWebSocket({
|
||
traffic: applyTrafficEvent,
|
||
client_stats: applyClientStatsEvent,
|
||
});
|
||
|
||
const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
|
||
const [formOpen, setFormOpen] = useState(false);
|
||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
||
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
||
const [infoOpen, setInfoOpen] = useState(false);
|
||
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
||
const [qrOpen, setQrOpen] = useState(false);
|
||
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
|
||
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
||
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
||
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
||
const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
|
||
const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
|
||
const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||
|
||
const initial = readFilterState();
|
||
const [searchKey, setSearchKey] = useState(initial.searchKey);
|
||
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
|
||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||
|
||
const [sortColumn, setSortColumn] = useState<string | null>(DEFAULT_SORT.column);
|
||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.order);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [tablePageSize, setTablePageSize] = useState(25);
|
||
// debouncedSearch lags behind the input so we don't spam the server on every
|
||
// keystroke; the search box still feels instant locally.
|
||
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters }));
|
||
}, [searchKey, filters]);
|
||
|
||
useEffect(() => {
|
||
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
||
return () => window.clearTimeout(handle);
|
||
}, [searchKey]);
|
||
|
||
useEffect(() => {
|
||
// Reset to page 1 whenever a filter or sort changes — otherwise an empty
|
||
// result set on a high page number leaves the user staring at "no clients".
|
||
setCurrentPage(1);
|
||
}, [debouncedSearch, filters, sortColumn, sortOrder]);
|
||
|
||
useEffect(() => {
|
||
setQuery({
|
||
page: currentPage,
|
||
pageSize: tablePageSize,
|
||
search: debouncedSearch,
|
||
filter: filters.buckets.join(','),
|
||
protocol: filters.protocols.join(','),
|
||
inbound: filters.inboundIds.join(','),
|
||
expiryFrom: filters.expiryFrom,
|
||
expiryTo: filters.expiryTo,
|
||
usageFrom: gbToBytes(filters.usageFromGB),
|
||
usageTo: gbToBytes(filters.usageToGB),
|
||
autoRenew: filters.autoRenew || undefined,
|
||
hasTgId: filters.hasTgId || undefined,
|
||
hasComment: filters.hasComment || undefined,
|
||
group: filters.groups.join(',') || undefined,
|
||
sort: sortColumn || undefined,
|
||
order: sortOrder || undefined,
|
||
});
|
||
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
|
||
|
||
const activeCount = activeFilterCount(filters);
|
||
|
||
useEffect(() => {
|
||
if (pageSize > 0) {
|
||
|
||
setTablePageSize(pageSize);
|
||
}
|
||
}, [pageSize]);
|
||
|
||
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
||
const inboundsById = useMemo(() => {
|
||
const out: Record<number, InboundOption> = {};
|
||
for (const ib of inbounds) out[ib.id] = ib;
|
||
return out;
|
||
}, [inbounds]);
|
||
|
||
const protocolOptions = useMemo(() => {
|
||
const values = new Set<string>((inbounds || []).map((i) => i.protocol).filter((x): x is string => !!x));
|
||
return [...values].sort();
|
||
}, [inbounds]);
|
||
|
||
const groupOptions = useMemo(() => {
|
||
const values = new Set<string>(allGroups);
|
||
for (const g of filters.groups) values.add(g);
|
||
return [...values].sort((a, b) => a.localeCompare(b));
|
||
}, [allGroups, filters.groups]);
|
||
|
||
const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
|
||
|
||
function inboundLabel(id: number) {
|
||
const ib = inboundsById[id];
|
||
return ib?.tag ?? '';
|
||
}
|
||
|
||
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 onBulkUngroup() {
|
||
const emails = [...selectedRowKeys];
|
||
if (emails.length === 0) return;
|
||
modal.confirm({
|
||
title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
|
||
content: t('pages.clients.ungroupConfirmContent'),
|
||
okText: t('confirm'),
|
||
okType: 'danger',
|
||
cancelText: t('cancel'),
|
||
onOk: async () => {
|
||
const msg = await bulkRemoveFromGroup(emails);
|
||
if (msg?.success) {
|
||
setSelectedRowKeys([]);
|
||
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
|
||
messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
function 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.clientInfo')}>
|
||
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
||
</Tooltip>
|
||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
||
</Tooltip>
|
||
<Tooltip title={t('edit')}>
|
||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||
</Tooltip>
|
||
<Tooltip title={t('delete')}>
|
||
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||
</Tooltip>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: t('pages.clients.enabled'),
|
||
key: 'enable',
|
||
width: 80,
|
||
render: (_v, record) => (
|
||
<Switch
|
||
checked={!!record.enable}
|
||
size="small"
|
||
loading={togglingEmail === record.email}
|
||
onChange={(next) => onToggleEnable(record, next)}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: t('pages.clients.online'),
|
||
key: 'online',
|
||
width: 90,
|
||
render: (_v, record) => {
|
||
const bucket = clientBucket(record);
|
||
if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
|
||
if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
|
||
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
|
||
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
|
||
return <Tag>{t('pages.clients.offline')}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: t('pages.clients.client'),
|
||
key: 'email',
|
||
render: (_v, record) => (
|
||
<div className="email-cell">
|
||
<span className="email">{record.email}</span>
|
||
{record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
|
||
{record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: t('pages.clients.group'),
|
||
key: 'group',
|
||
width: 130,
|
||
hidden: allGroups.length === 0,
|
||
render: (_v, record) => {
|
||
if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
||
const isActive = filters.groups.includes(record.group);
|
||
return (
|
||
<Tag
|
||
color="geekblue"
|
||
style={{ margin: 0, cursor: 'pointer', opacity: isActive ? 0.6 : 1 }}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (!isActive) {
|
||
setFilters({ ...filters, groups: [...filters.groups, record.group!] });
|
||
}
|
||
}}
|
||
>
|
||
{record.group}
|
||
</Tag>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: t('pages.clients.attachedInbounds'),
|
||
key: 'inboundIds',
|
||
width: 170,
|
||
render: (_v, record) => {
|
||
const ids = record.inboundIds || [];
|
||
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
||
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
|
||
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
|
||
const chip = (id: number, compact: boolean) => {
|
||
const ib = inboundsById[id];
|
||
const proto = (ib?.protocol || '').toLowerCase();
|
||
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
|
||
const compactLabel = ib?.tag ?? '';
|
||
return (
|
||
<Tooltip key={id} title={inboundLabel(id)}>
|
||
<Tag color={color} style={{ margin: 2 }}>
|
||
{compact ? compactLabel : inboundLabel(id)}
|
||
</Tag>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
return (
|
||
<>
|
||
{visible.map((id) => chip(id, true))}
|
||
{overflow.length > 0 && (
|
||
<Popover
|
||
trigger="click"
|
||
placement="bottomRight"
|
||
content={
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
|
||
{overflow.map((id) => chip(id, false))}
|
||
</div>
|
||
}
|
||
>
|
||
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
|
||
+{overflow.length}
|
||
</Tag>
|
||
</Popover>
|
||
)}
|
||
</>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: t('pages.clients.traffic'),
|
||
key: 'traffic',
|
||
render: (_v, record) => trafficLabel(record),
|
||
},
|
||
{
|
||
title: t('pages.clients.remaining'),
|
||
key: 'remaining',
|
||
width: 130,
|
||
render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
|
||
},
|
||
{
|
||
title: t('pages.clients.duration'),
|
||
key: 'expiryTime',
|
||
render: (_v, record) => (
|
||
<Tooltip title={expiryLabel(record)}>
|
||
<Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
|
||
</Tooltip>
|
||
),
|
||
},
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups]);
|
||
|
||
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">
|
||
{selectedRowKeys.length === 0 ? (
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||
{!isMobile && t('pages.clients.addClients')}
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Tag
|
||
color="blue"
|
||
closable
|
||
onClose={() => setSelectedRowKeys([])}
|
||
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
|
||
>
|
||
{t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
|
||
</Tag>
|
||
<Button icon={<UsergroupAddOutlined />} onClick={() => setBulkAttachOpen(true)}>
|
||
{!isMobile && t('pages.clients.attach')}
|
||
</Button>
|
||
<Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
|
||
{!isMobile && t('pages.clients.detach')}
|
||
</Button>
|
||
<Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
|
||
{!isMobile && t('pages.clients.addToGroup')}
|
||
</Button>
|
||
<Button danger icon={<UngroupIcon />} onClick={onBulkUngroup}>
|
||
{!isMobile && t('pages.clients.ungroup')}
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Dropdown
|
||
trigger={['click']}
|
||
placement="bottomRight"
|
||
menu={{
|
||
items: selectedRowKeys.length > 0
|
||
? [
|
||
{
|
||
key: 'adjust',
|
||
icon: <ClockCircleOutlined />,
|
||
label: t('pages.clients.adjust'),
|
||
onClick: () => setBulkAdjustOpen(true),
|
||
},
|
||
{
|
||
key: 'subLinks',
|
||
icon: <LinkOutlined />,
|
||
label: t('pages.clients.subLinks'),
|
||
onClick: () => setSubLinksOpen(true),
|
||
},
|
||
]
|
||
: [
|
||
{
|
||
key: 'bulk',
|
||
icon: <UsergroupAddOutlined />,
|
||
label: t('pages.clients.bulk'),
|
||
onClick: () => setBulkAddOpen(true),
|
||
},
|
||
{
|
||
key: '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>
|
||
{selectedRowKeys.length > 0 && (
|
||
<Button
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
onClick={onBulkDelete}
|
||
style={{ marginInlineStart: 'auto' }}
|
||
>
|
||
{!isMobile && t('delete')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
}
|
||
>
|
||
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
|
||
<Input
|
||
value={searchKey}
|
||
onChange={(e) => setSearchKey(e.target.value)}
|
||
placeholder={t('pages.clients.searchPlaceholder')}
|
||
allowClear
|
||
prefix={<SearchOutlined />}
|
||
size={isMobile ? 'small' : 'middle'}
|
||
style={{ maxWidth: 320 }}
|
||
/>
|
||
<Badge count={activeCount} size="small" offset={[-4, 4]}>
|
||
<Button
|
||
icon={<FilterOutlined />}
|
||
size={isMobile ? 'small' : 'middle'}
|
||
onClick={() => setFilterDrawerOpen(true)}
|
||
type={activeCount > 0 ? 'primary' : 'default'}
|
||
>
|
||
{!isMobile && t('filter')}
|
||
</Button>
|
||
</Badge>
|
||
<Select
|
||
value={sortValueFor(sortColumn, sortOrder)}
|
||
size={isMobile ? 'small' : 'middle'}
|
||
suffixIcon={<SortAscendingOutlined />}
|
||
style={{ minWidth: isMobile ? 130 : 200 }}
|
||
onChange={(value) => {
|
||
const opt = SORT_OPTIONS.find((o) => o.value === value);
|
||
setSortColumn(opt?.column ?? null);
|
||
setSortOrder(opt?.order ?? null);
|
||
}}
|
||
options={SORT_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||
/>
|
||
{activeCount > 0 && (
|
||
<Button
|
||
size={isMobile ? 'small' : 'middle'}
|
||
onClick={() => setFilters(emptyFilters())}
|
||
>
|
||
{t('pages.clients.clearAllFilters')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{activeCount > 0 && (
|
||
<div className="filter-chips">
|
||
{filters.buckets.map((b) => (
|
||
<Tag
|
||
key={`b-${b}`}
|
||
closable
|
||
onClose={() => setFilters({ ...filters, buckets: filters.buckets.filter((x) => x !== b) })}
|
||
>
|
||
{bucketChipLabel(b, t)}
|
||
</Tag>
|
||
))}
|
||
{filters.protocols.map((p) => (
|
||
<Tag
|
||
key={`p-${p}`}
|
||
closable
|
||
color="blue"
|
||
onClose={() => setFilters({ ...filters, protocols: filters.protocols.filter((x) => x !== p) })}
|
||
>
|
||
{p}
|
||
</Tag>
|
||
))}
|
||
{filters.inboundIds.map((id) => (
|
||
<Tag
|
||
key={`i-${id}`}
|
||
closable
|
||
color="cyan"
|
||
onClose={() => setFilters({ ...filters, inboundIds: filters.inboundIds.filter((x) => x !== id) })}
|
||
>
|
||
{inboundLabel(id)}
|
||
</Tag>
|
||
))}
|
||
{filters.groups.map((g) => (
|
||
<Tag
|
||
key={`g-${g}`}
|
||
closable
|
||
color="geekblue"
|
||
onClose={() => setFilters({ ...filters, groups: filters.groups.filter((x) => x !== g) })}
|
||
>
|
||
{t('pages.clients.group')}: {g}
|
||
</Tag>
|
||
))}
|
||
{(filters.expiryFrom || filters.expiryTo) && (
|
||
<Tag closable color="purple" onClose={() => clearOneFilter('expiryFrom')}>
|
||
{t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'}
|
||
{' → '}
|
||
{filters.expiryTo ? IntlUtil.formatDate(filters.expiryTo, datepicker) : '…'}
|
||
</Tag>
|
||
)}
|
||
{(filters.usageFromGB || filters.usageToGB) && (
|
||
<Tag closable color="orange" onClose={() => clearOneFilter('usageFromGB')}>
|
||
{t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}{filters.usageToGB ? `–${filters.usageToGB}` : '+'} GB
|
||
</Tag>
|
||
)}
|
||
{filters.autoRenew && (
|
||
<Tag closable color="gold" onClose={() => clearOneFilter('autoRenew')}>
|
||
{t('pages.clients.renew')}: {filters.autoRenew === 'on' ? t('enabled') : t('disabled')}
|
||
</Tag>
|
||
)}
|
||
{filters.hasTgId && (
|
||
<Tag closable onClose={() => clearOneFilter('hasTgId')}>
|
||
{t('pages.clients.telegramId')}: {filters.hasTgId === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
|
||
</Tag>
|
||
)}
|
||
{filters.hasComment && (
|
||
<Tag closable onClose={() => clearOneFilter('hasComment')}>
|
||
{t('pages.clients.comment')}: {filters.hasComment === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!isMobile ? (
|
||
<Table<ClientRecord>
|
||
columns={columns}
|
||
dataSource={sortedClients}
|
||
loading={loading}
|
||
rowKey="email"
|
||
rowSelection={rowSelection}
|
||
pagination={tablePagination}
|
||
size="small"
|
||
scroll={{ x: 1200 }}
|
||
onChange={onTableChange}
|
||
locale={{
|
||
emptyText: (
|
||
<div className="clients-empty">
|
||
<TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||
<div>{t('noData')}</div>
|
||
</div>
|
||
),
|
||
}}
|
||
/>
|
||
) : (
|
||
<Spin spinning={loading}>
|
||
<div className="client-cards">
|
||
{filteredClients.length > 0 && (
|
||
<div className="card-bulk-bar">
|
||
<Checkbox
|
||
checked={allSelected}
|
||
indeterminate={someSelected}
|
||
onChange={(e) => selectAll(e.target.checked)}
|
||
>
|
||
{t('pages.clients.selectAll')}
|
||
</Checkbox>
|
||
{selectedRowKeys.length > 0 && (
|
||
<span className="bulk-count">{selectedRowKeys.length}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{filteredClients.length === 0 && (
|
||
<div className="card-empty">
|
||
<TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
||
<div>{t('noData')}</div>
|
||
</div>
|
||
)}
|
||
{filteredClients.length > 0 && (
|
||
<div className="card-pagination">
|
||
<Pagination
|
||
current={currentPage}
|
||
pageSize={tablePageSize}
|
||
total={filtered}
|
||
showSizeChanger={filtered > 10}
|
||
pageSizeOptions={['10', '25', '50', '100', '200']}
|
||
hideOnSinglePage={filtered <= tablePageSize}
|
||
size="small"
|
||
showTotal={(n) => `${n}`}
|
||
onChange={(p, s) => {
|
||
setCurrentPage(p);
|
||
if (s && s !== tablePageSize) setTablePageSize(s);
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
{filteredClients.map((row) => {
|
||
const bucket = clientBucket(row);
|
||
return (
|
||
<div key={row.email} className={`client-card${selectedRowKeys.includes(row.email) ? ' is-selected' : ''}`}>
|
||
<div className="card-head">
|
||
<Checkbox
|
||
checked={selectedRowKeys.includes(row.email)}
|
||
onChange={(e) => toggleSelect(row.email, e.target.checked)}
|
||
/>
|
||
<Badge status={bucketBadgeStatus(bucket)} />
|
||
<span className="tag-name">{row.email}</span>
|
||
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
|
||
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
|
||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||
<Tooltip title={t('pages.clients.clientInfo')}>
|
||
<InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
|
||
</Tooltip>
|
||
<Switch
|
||
checked={!!row.enable}
|
||
size="small"
|
||
loading={togglingEmail === row.email}
|
||
onChange={(next) => onToggleEnable(row, next)}
|
||
/>
|
||
<Dropdown
|
||
trigger={['click']}
|
||
placement="bottomRight"
|
||
menu={{
|
||
items: [
|
||
{
|
||
key: 'qr',
|
||
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
|
||
onClick: () => onShowQr(row),
|
||
},
|
||
{
|
||
key: 'reset',
|
||
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
|
||
onClick: () => onResetTraffic(row),
|
||
},
|
||
{
|
||
key: 'edit',
|
||
label: <><EditOutlined /> {t('edit')}</>,
|
||
onClick: () => onEdit(row),
|
||
},
|
||
{
|
||
key: 'delete',
|
||
danger: true,
|
||
label: <><DeleteOutlined /> {t('delete')}</>,
|
||
onClick: () => onDelete(row),
|
||
},
|
||
],
|
||
}}
|
||
>
|
||
<MoreOutlined className="row-action-trigger" />
|
||
</Dropdown>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Spin>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
)}
|
||
</Spin>
|
||
</Layout.Content>
|
||
</Layout>
|
||
|
||
<LazyMount when={formOpen}>
|
||
<ClientFormModal
|
||
open={formOpen}
|
||
mode={formMode}
|
||
client={editingClient}
|
||
attachedIds={editingAttachedIds}
|
||
inbounds={inbounds}
|
||
ipLimitEnable={ipLimitEnable}
|
||
tgBotEnable={tgBotEnable}
|
||
groups={allGroups}
|
||
save={onSave}
|
||
onOpenChange={setFormOpen}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={infoOpen}>
|
||
<ClientInfoModal
|
||
open={infoOpen}
|
||
client={infoClient}
|
||
inboundsById={inboundsById}
|
||
isOnline={infoClient ? isOnline(infoClient.email) : false}
|
||
subSettings={subSettings}
|
||
onOpenChange={setInfoOpen}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={qrOpen}>
|
||
<ClientQrModal
|
||
open={qrOpen}
|
||
client={qrClient}
|
||
subSettings={subSettings}
|
||
onOpenChange={setQrOpen}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={bulkAddOpen}>
|
||
<ClientBulkAddModal
|
||
open={bulkAddOpen}
|
||
inbounds={inbounds}
|
||
ipLimitEnable={ipLimitEnable}
|
||
groups={allGroups}
|
||
onOpenChange={setBulkAddOpen}
|
||
onSaved={() => setBulkAddOpen(false)}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={bulkAdjustOpen}>
|
||
<ClientBulkAdjustModal
|
||
open={bulkAdjustOpen}
|
||
count={selectedRowKeys.length}
|
||
onOpenChange={setBulkAdjustOpen}
|
||
onSubmit={async (addDays, addBytes) => {
|
||
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
|
||
if (msg?.success) {
|
||
setSelectedRowKeys([]);
|
||
return msg.obj ?? { adjusted: 0 };
|
||
}
|
||
return null;
|
||
}}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={subLinksOpen}>
|
||
<SubLinksModal
|
||
open={subLinksOpen}
|
||
emails={selectedRowKeys}
|
||
clients={clients}
|
||
subSettings={subSettings}
|
||
onOpenChange={setSubLinksOpen}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={bulkGroupOpen}>
|
||
<BulkAddToGroupModal
|
||
open={bulkGroupOpen}
|
||
count={selectedRowKeys.length}
|
||
groups={allGroups}
|
||
onOpenChange={setBulkGroupOpen}
|
||
onSubmit={async (group) => {
|
||
const msg = await bulkAddToGroup([...selectedRowKeys], group);
|
||
if (msg?.success) {
|
||
setSelectedRowKeys([]);
|
||
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||
}
|
||
return null;
|
||
}}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={bulkAttachOpen}>
|
||
<BulkAttachInboundsModal
|
||
open={bulkAttachOpen}
|
||
count={selectedRowKeys.length}
|
||
inbounds={inbounds}
|
||
onOpenChange={setBulkAttachOpen}
|
||
onSubmit={async (inboundIds) => {
|
||
const msg = await bulkAttach([...selectedRowKeys], inboundIds);
|
||
if (msg?.success) {
|
||
setSelectedRowKeys([]);
|
||
return msg.obj ?? { attached: [], skipped: [], errors: [] };
|
||
}
|
||
return null;
|
||
}}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={bulkDetachOpen}>
|
||
<BulkDetachInboundsModal
|
||
open={bulkDetachOpen}
|
||
count={selectedRowKeys.length}
|
||
inbounds={inbounds}
|
||
onOpenChange={setBulkDetachOpen}
|
||
onSubmit={async (inboundIds) => {
|
||
const msg = await bulkDetach([...selectedRowKeys], inboundIds);
|
||
if (msg?.success) {
|
||
setSelectedRowKeys([]);
|
||
return msg.obj ?? { detached: [], skipped: [], errors: [] };
|
||
}
|
||
return null;
|
||
}}
|
||
/>
|
||
</LazyMount>
|
||
<LazyMount when={filterDrawerOpen}>
|
||
<FilterDrawer
|
||
open={filterDrawerOpen}
|
||
onOpenChange={setFilterDrawerOpen}
|
||
filters={filters}
|
||
onChange={setFilters}
|
||
inbounds={inbounds}
|
||
protocols={protocolOptions}
|
||
groups={groupOptions}
|
||
/>
|
||
</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;
|
||
}
|
||
}
|