feat(clients): add bulk enable/disable and move selection actions into More menu

Add bulkEnable/bulkDisable named endpoints backed by a shared internal impl, and consolidate the per-selection actions (attach, detach, add to group, ungroup, enable, disable, adjust, sub links) into the clients table's More dropdown so the toolbar only shows the selection count and delete. Translate the new enable/disable confirm dialogs and toasts across all 13 locales.
This commit is contained in:
MHSanaei
2026-06-25 19:21:42 +02:00
parent a4be5a0deb
commit e64e998194
20 changed files with 698 additions and 23 deletions
+21
View File
@@ -14,6 +14,7 @@ import {
BulkAttachResultSchema,
BulkCreateResultSchema,
BulkDeleteResultSchema,
BulkSetEnableResultSchema,
BulkDetachResultSchema,
DelDepletedResultSchema,
type ClientHydrate,
@@ -27,6 +28,7 @@ import {
type BulkAttachResult,
type BulkCreateResult,
type BulkDeleteResult,
type BulkSetEnableResult,
type BulkDetachResult,
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
@@ -348,6 +350,15 @@ export function useClients() {
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkSetEnableMut = useMutation({
mutationFn: async (payload: { emails: string[]; enable: boolean }): Promise<Msg<BulkSetEnableResult>> => {
const path = payload.enable ? '/panel/api/clients/bulkEnable' : '/panel/api/clients/bulkDisable';
const raw = await HttpUtil.post(path, { emails: payload.emails }, JSON_HEADERS);
return parseMsg(raw, BulkSetEnableResultSchema, payload.enable ? 'clients/bulkEnable' : 'clients/bulkDisable');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const attachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
@@ -439,6 +450,14 @@ export function useClients() {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
}, [bulkAdjustMut]);
const bulkEnable = useCallback((emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
return bulkSetEnableMut.mutateAsync({ emails, enable: true });
}, [bulkSetEnableMut]);
const bulkDisable = useCallback((emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
return bulkSetEnableMut.mutateAsync({ emails, enable: false });
}, [bulkSetEnableMut]);
const bulkAddToGroup = useCallback((emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAddToGroupMut.mutateAsync({ emails, group });
@@ -590,6 +609,8 @@ export function useClients() {
remove,
bulkDelete,
bulkAdjust,
bulkEnable,
bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
+14
View File
@@ -648,6 +648,20 @@ export const sections: readonly Section[] = [
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200,\n "flow": "xtls-rprx-vision"\n}',
response: '{\n "success": true,\n "obj": {\n "adjusted": 2,\n "skipped": [\n { "email": "carol", "reason": "unlimited expiry" }\n ]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/bulkEnable',
summary: 'Enable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to add each user. Note that enabling a client whose quota is exhausted or whose expiry has passed only flips the flag — the traffic loop will disable it again on the next tick. Returns the changed count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"]\n}',
response: '{\n "success": true,\n "obj": {\n "changed": 2,\n "skipped": [\n { "email": "carol", "reason": "client not found" }\n ]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/bulkDisable',
summary: 'Disable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to remove each user. Returns the changed count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"]\n}',
response: '{\n "success": true,\n "obj": {\n "changed": 2,\n "skipped": [\n { "email": "carol", "reason": "client not found" }\n ]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/bulkDel',
+80 -23
View File
@@ -27,6 +27,7 @@ import {
} from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import {
CheckCircleOutlined,
ClockCircleOutlined,
DeleteOutlined,
DisconnectOutlined,
@@ -42,6 +43,7 @@ import {
RetweetOutlined,
SearchOutlined,
SortAscendingOutlined,
StopOutlined,
TagsOutlined,
TeamOutlined,
UploadOutlined,
@@ -204,7 +206,7 @@ export default function ClientsPage() {
setQuery,
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
applyTrafficEvent, applyClientStatsEvent,
refresh,
@@ -641,6 +643,35 @@ export default function ClientsPage() {
});
}
function onBulkSetEnable(enable: boolean) {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t(enable ? 'pages.clients.bulkEnableConfirmTitle' : 'pages.clients.bulkDisableConfirmTitle', { count: emails.length }),
content: t(enable ? 'pages.clients.bulkEnableConfirmContent' : 'pages.clients.bulkDisableConfirmContent'),
okText: t('confirm'),
okType: enable ? 'primary' : 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = enable ? await bulkEnable(emails) : await bulkDisable(emails);
setSelectedRowKeys([]);
const changed = msg?.obj?.changed ?? 0;
const skipped = msg?.obj?.skipped ?? [];
const failed = skipped.length;
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
const okKey = enable ? 'pages.clients.toasts.bulkEnabled' : 'pages.clients.toasts.bulkDisabled';
const mixedKey = enable ? 'pages.clients.toasts.bulkEnabledMixed' : 'pages.clients.toasts.bulkDisabledMixed';
if (failed === 0 && msg?.success) {
messageApi.success(t(okKey, { count: changed }));
} else {
messageApi.warning(firstError
? `${t(mixedKey, { ok: changed, failed })} — ${firstError}`
: t(mixedKey, { ok: changed, failed }));
}
},
});
}
function onBulkDelete() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
@@ -1012,28 +1043,14 @@ export default function ClientsPage() {
{!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>
</>
<Tag
color="blue"
closable
onClose={() => setSelectedRowKeys([])}
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
>
{t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
</Tag>
)}
<Dropdown
trigger={['click']}
@@ -1041,6 +1058,46 @@ export default function ClientsPage() {
menu={{
items: selectedRowKeys.length > 0
? [
{
key: 'attach',
icon: <UsergroupAddOutlined />,
label: t('pages.clients.attach'),
onClick: () => setBulkAttachOpen(true),
},
{
key: 'detach',
icon: <UsergroupDeleteOutlined />,
label: t('pages.clients.detach'),
danger: true,
onClick: () => setBulkDetachOpen(true),
},
{
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true),
},
{
key: 'ungroup',
icon: <UngroupIcon />,
label: t('pages.clients.ungroup'),
danger: true,
onClick: onBulkUngroup,
},
{ type: 'divider' as const },
{
key: 'enable',
icon: <CheckCircleOutlined />,
label: t('pages.clients.enable'),
onClick: () => onBulkSetEnable(true),
},
{
key: 'disable',
icon: <StopOutlined />,
label: t('pages.clients.disable'),
danger: true,
onClick: () => onBulkSetEnable(false),
},
{
key: 'adjust',
icon: <ClockCircleOutlined />,
+8
View File
@@ -101,6 +101,13 @@ export const BulkDeleteResultSchema = z.object({
.optional(),
});
export const BulkSetEnableResultSchema = z.object({
changed: z.number(),
skipped: z
.array(z.object({ email: z.string(), reason: z.string() }))
.optional(),
});
export const BulkCreateResultSchema = z.object({
created: z.number(),
skipped: z
@@ -221,6 +228,7 @@ export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
export type BulkDeleteResult = z.infer<typeof BulkDeleteResultSchema>;
export type BulkSetEnableResult = z.infer<typeof BulkSetEnableResultSchema>;
export type BulkCreateResult = z.infer<typeof BulkCreateResultSchema>;
export type BulkAttachResult = z.infer<typeof BulkAttachResultSchema>;
export type BulkDetachResult = z.infer<typeof BulkDetachResultSchema>;