diff --git a/database/db.go b/database/db.go index c92e315f..fba55a3c 100644 --- a/database/db.go +++ b/database/db.go @@ -346,7 +346,15 @@ func isTableEmpty(tableName string) (bool, error) { func InitDB(dbPath string) error { var gormLogger logger.Interface if config.IsDebug() { - gormLogger = logger.Default + gormLogger = logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Info, + IgnoreRecordNotFoundError: true, + Colorful: true, + }, + ) } else { gormLogger = logger.Discard } 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/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 43358b78..1b15267f 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -308,7 +308,7 @@ export default function ClientBulkAddModal({ )} - update('totalGB', Number(v) || 0)} /> + update('totalGB', Number(v) || 0)} /> 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/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 67b07647..616e24ab 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -393,7 +393,7 @@ export default function ClientFormModal({ - update('totalGB', Number(v) || 0)} /> diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 3018fadb..788df288 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(); @@ -587,7 +590,7 @@ export default function ClientsPage() { }, 'expiryTime'), ]; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline]); + }, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline, inboundsById]); const tablePagination = { current: currentPage, @@ -700,9 +703,14 @@ export default function ClientsPage() { {!isMobile && t('pages.clients.bulk')} {selectedRowKeys.length > 0 && ( - + <> + + + )}