From 3ac65b6fe7571a87aa538bf873a6c1efe0155dc7 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 23 May 2026 15:55:04 +0200 Subject: [PATCH] feat(clients): bulk extend expiry / traffic for selected clients Adds POST /panel/api/clients/bulkAdjust which shifts ExpiryTime by addDays and TotalGB by addBytes for every email in one request. The endpoint is wired into the clients page through a new ClientBulkAdjustModal that opens from the existing multi-select toolbar. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field so bulk extend never accidentally converts an unlimited client to a limited one. Negative values are allowed for refunds / corrections. Translations added for all 13 locales. --- frontend/src/hooks/useClients.ts | 12 +++ frontend/src/pages/api-docs/endpoints.js | 7 ++ .../pages/clients/ClientBulkAdjustModal.tsx | 97 +++++++++++++++++++ frontend/src/pages/clients/ClientsPage.tsx | 29 +++++- web/controller/client.go | 25 +++++ web/service/client.go | 79 +++++++++++++++ web/translation/ar-EG.json | 8 ++ web/translation/en-US.json | 8 ++ web/translation/es-ES.json | 8 ++ web/translation/fa-IR.json | 8 ++ web/translation/id-ID.json | 8 ++ web/translation/ja-JP.json | 8 ++ web/translation/pt-BR.json | 8 ++ web/translation/ru-RU.json | 8 ++ web/translation/tr-TR.json | 8 ++ web/translation/uk-UA.json | 8 ++ web/translation/vi-VN.json | 8 ++ web/translation/zh-CN.json | 8 ++ web/translation/zh-TW.json | 8 ++ 19 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/clients/ClientBulkAdjustModal.tsx diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index ea3799db..a2089ad9 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -146,6 +146,17 @@ export function useClients() { return results; }, [refresh]); + const bulkAdjust = useCallback(async (emails: string[], addDays: number, addBytes: number) => { + if (!Array.isArray(emails) || emails.length === 0) return null; + const msg = await HttpUtil.post( + '/panel/api/clients/bulkAdjust', + { emails, addDays, addBytes }, + JSON_HEADERS, + ) as ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>; + if (msg?.success) await refresh(); + return msg; + }, [refresh]); + const attach = useCallback(async (email: string, inboundIds: number[]) => { if (!email) return null; const encoded = encodeURIComponent(email); @@ -269,6 +280,7 @@ export function useClients() { update, remove, removeMany, + bulkAdjust, attach, detach, resetTraffic, diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 431e1e08..1fca7994 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -461,6 +461,13 @@ export const sections = [ summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.', response: '{\n "success": true,\n "obj": {\n "deleted": 0\n }\n}', }, + { + method: 'POST', + path: '/panel/api/clients/bulkAdjust', + summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.', + body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200\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/resetTraffic/:email', diff --git a/frontend/src/pages/clients/ClientBulkAdjustModal.tsx b/frontend/src/pages/clients/ClientBulkAdjustModal.tsx new file mode 100644 index 00000000..b13dcdea --- /dev/null +++ b/frontend/src/pages/clients/ClientBulkAdjustModal.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Form, InputNumber, Modal, message } from 'antd'; + +const GB = 1024 * 1024 * 1024; + +interface ClientBulkAdjustModalProps { + open: boolean; + count: number; + onOpenChange: (open: boolean) => void; + onSubmit: (addDays: number, addBytes: number) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>; +} + +export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) { + const { t } = useTranslation(); + const [messageApi, messageContextHolder] = message.useMessage(); + const [addDays, setAddDays] = useState(0); + const [addGB, setAddGB] = useState(0); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setAddDays(0); + setAddGB(0); + } + }, [open]); + + async function handleOk() { + const days = Math.trunc(Number(addDays) || 0); + const gb = Number(addGB) || 0; + if (days === 0 && gb === 0) { + messageApi.warning(t('pages.clients.bulkAdjustNothing')); + return; + } + setSubmitting(true); + try { + const bytes = Math.trunc(gb * GB); + const result = await onSubmit(days, bytes); + if (!result) return; + const ok = result.adjusted ?? 0; + const skipped = result.skipped?.length ?? 0; + if (skipped === 0) { + messageApi.success(t('pages.clients.toasts.bulkAdjusted', { count: ok })); + } else { + const firstReason = result.skipped?.[0]?.reason ?? ''; + messageApi.warning(firstReason + ? `${t('pages.clients.toasts.bulkAdjustedMixed', { ok, skipped })} — ${firstReason}` + : t('pages.clients.toasts.bulkAdjustedMixed', { ok, skipped })); + } + onOpenChange(false); + } finally { + setSubmitting(false); + } + } + + return ( + <> + {messageContextHolder} + onOpenChange(false)} + destroyOnHidden + > + +
+ + setAddDays(Number(v) || 0)} + style={{ width: '100%' }} + step={1} + precision={0} + /> + + + setAddGB(Number(v) || 0)} + style={{ width: '100%' }} + step={1} + /> + +
+
+ + ); +} diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 3018fadb..5fbeeae8 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -25,6 +25,7 @@ import { } from 'antd'; import type { ColumnsType, TableProps } from 'antd/es/table'; import { + ClockCircleOutlined, DeleteOutlined, EditOutlined, FilterOutlined, @@ -54,6 +55,7 @@ import ClientFormModal from './ClientFormModal'; import ClientInfoModal from './ClientInfoModal'; import ClientQrModal from './ClientQrModal'; import ClientBulkAddModal from './ClientBulkAddModal'; +import ClientBulkAdjustModal from './ClientBulkAdjustModal'; import '@/styles/page-cards.css'; import './ClientsPage.css'; @@ -96,7 +98,7 @@ export default function ClientsPage() { const { clients, inbounds, onlines, loading, fetched, subSettings, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, - create, update, remove, removeMany, attach, detach, + create, update, remove, removeMany, bulkAdjust, attach, detach, resetTraffic, resetAllTraffics, delDepleted, setEnable, applyTrafficEvent, applyClientStatsEvent, applyInvalidate, } = useClients(); @@ -117,6 +119,7 @@ export default function ClientsPage() { const [qrOpen, setQrOpen] = useState(false); const [qrClient, setQrClient] = useState(null); const [bulkAddOpen, setBulkAddOpen] = useState(false); + const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const initial = readFilterState(); @@ -700,9 +703,14 @@ export default function ClientsPage() { {!isMobile && t('pages.clients.bulk')} {selectedRowKeys.length > 0 && ( - + <> + + + )}