From e23599cb182ecbee5c8ede0a14f47feab83d254f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 27 May 2026 18:17:44 +0200 Subject: [PATCH] feat(inbounds): row action to delete all clients of an inbound Adds POST /panel/api/inbounds/:id/delAllClients that collects every client email from settings.clients[] and runs ClientService.BulkDelete in one pass. Row action lives in the More menu as a danger item, only shown for multi-user inbounds that currently have at least one client; confirmation modal displays the live client count. --- frontend/src/pages/inbounds/InboundList.tsx | 15 +++++--- frontend/src/pages/inbounds/InboundsPage.tsx | 21 +++++++++++- web/controller/inbound.go | 36 ++++++++++++++++++++ web/service/inbound.go | 21 ++++++++++++ web/translation/ar-EG.json | 3 ++ web/translation/en-US.json | 3 ++ web/translation/es-ES.json | 3 ++ web/translation/fa-IR.json | 3 ++ web/translation/id-ID.json | 3 ++ web/translation/ja-JP.json | 3 ++ web/translation/pt-BR.json | 3 ++ web/translation/ru-RU.json | 3 ++ web/translation/tr-TR.json | 3 ++ web/translation/uk-UA.json | 3 ++ web/translation/vi-VN.json | 3 ++ web/translation/zh-CN.json | 3 ++ web/translation/zh-TW.json | 3 ++ 17 files changed, 127 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/inbounds/InboundList.tsx b/frontend/src/pages/inbounds/InboundList.tsx index dd82f2d2..422c5bcf 100644 --- a/frontend/src/pages/inbounds/InboundList.tsx +++ b/frontend/src/pages/inbounds/InboundList.tsx @@ -28,6 +28,7 @@ import { BlockOutlined, DeleteOutlined, InfoCircleOutlined, + UsergroupDeleteOutlined, } from '@ant-design/icons'; import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils'; @@ -167,6 +168,7 @@ export type RowAction = | 'clipboard' | 'delete' | 'resetTraffic' + | 'delAllClients' | 'clone'; export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds'; @@ -228,11 +230,12 @@ function showQrCodeMenu(dbInbound: DBInboundRecord): boolean { interface RowActionsMenuProps { record: DBInboundRecord; subEnable: boolean; + hasClients: boolean; onClick: (key: RowAction) => void; isMobile?: boolean; } -function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean }): MenuProps['items'] { +function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean; hasClients?: boolean }): MenuProps['items'] { const items: MenuProps['items'] = []; if (isMobile) { items.push({ key: 'edit', icon: , label: t('edit') }); @@ -255,11 +258,14 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInb items.push({ key: 'clipboard', icon: , label: t('pages.inbounds.exportInbound') }); items.push({ key: 'resetTraffic', icon: , label: t('pages.inbounds.resetTraffic') }); items.push({ key: 'clone', icon: , label: t('pages.inbounds.clone') }); + if (isInboundMultiUser(record) && hasClients) { + items.push({ key: 'delAllClients', icon: , danger: true, label: t('pages.inbounds.delAllClients') }); + } items.push({ key: 'delete', icon: , danger: true, label: t('delete') }); return items; } -function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) { +function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) { const { t } = useTranslation(); return (
@@ -267,7 +273,7 @@ function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) { onClick(key as RowAction), }} > @@ -350,6 +356,7 @@ export default function InboundList({ 0} onClick={(key) => onRowAction({ key, dbInbound: record })} /> ), @@ -623,7 +630,7 @@ export default function InboundList({ trigger={['click']} placement="bottomRight" menu={{ - items: buildRowActionsMenu({ record, subEnable, t, isMobile: true }), + items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }), onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }), }} > diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 42e24019..a63a7aea 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -48,6 +48,7 @@ type RowAction = | 'clipboard' | 'delete' | 'resetTraffic' + | 'delAllClients' | 'clone'; type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds'; @@ -355,6 +356,21 @@ export default function InboundsPage() { }); }, [modal, refresh, t]); + const confirmDelAllClients = useCallback((dbInbound: DBInbound) => { + const count = clientCount[dbInbound.id]?.clients || 0; + modal.confirm({ + title: t('pages.inbounds.delAllClientsConfirmTitle', { remark: dbInbound.remark, count }), + content: t('pages.inbounds.delAllClientsConfirmContent'), + okText: t('delete'), + okType: 'danger', + cancelText: t('cancel'), + onOk: async () => { + const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delAllClients`); + if (msg?.success) await refresh(); + }, + }); + }, [modal, refresh, t, clientCount]); + const confirmClone = useCallback((dbInbound: DBInbound) => { modal.confirm({ title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }), @@ -456,13 +472,16 @@ export default function InboundsPage() { case 'resetTraffic': confirmResetTraffic(target); break; + case 'delAllClients': + confirmDelAllClients(target); + break; case 'clone': confirmClone(target); break; default: messageApi.info(`Action "${key}" — coming in a later 5f subphase`); } - }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]); + }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmDelAllClients, confirmClone, messageApi]); return ( diff --git a/web/controller/inbound.go b/web/controller/inbound.go index eaeddb88..ba46e451 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -19,6 +19,7 @@ import ( // InboundController handles HTTP requests related to Xray inbounds management. type InboundController struct { inboundService service.InboundService + clientService service.ClientService xrayService service.XrayService fallbackService service.FallbackService } @@ -72,6 +73,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.POST("/update/:id", a.updateInbound) g.POST("/setEnable/:id", a.setInboundEnable) g.POST("/:id/resetTraffic", a.resetInboundTraffic) + g.POST("/:id/delAllClients", a.delAllInboundClients) g.POST("/resetAllTraffics", a.resetAllTraffics) g.POST("/import", a.importInbound) g.POST("/:id/fallbacks", a.setFallbacks) @@ -276,6 +278,40 @@ func (a *InboundController) resetInboundTraffic(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil) } +// delAllInboundClients removes every client attached to a specific inbound +// while keeping the inbound itself. Internally collects the current email +// list from settings.clients[] and feeds it into ClientService.BulkDelete, +// which handles per-inbound JSON rewriting, runtime user removal, traffic +// row cleanup, and the SyncInbound mapping pass in one optimized cycle. +func (a *InboundController) delAllInboundClients(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + emails, err := a.inboundService.EmailsByInbound(id) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + if len(emails) == 0 { + jsonObj(c, service.BulkDeleteResult{}, nil) + return + } + result, needRestart, err := a.clientService.BulkDelete(&a.inboundService, emails, false) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, result, nil) + if needRestart { + a.xrayService.SetToNeedRestart() + } + user := session.GetLoginUser(c) + a.broadcastInboundsUpdate(user.Id) + notifyClientsChanged() +} + // resetAllTraffics resets all traffic counters across all inbounds. func (a *InboundController) resetAllTraffics(c *gin.Context) { err := a.inboundService.ResetAllTraffics() diff --git a/web/service/inbound.go b/web/service/inbound.go index 066ebd4b..3ab380b8 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -2415,6 +2415,27 @@ func (s *InboundService) ResetInboundTraffic(id int) error { }) } +// EmailsByInbound returns the list of client emails currently configured on +// an inbound's settings.clients[]. Used by the "delete all clients" flow on +// the inbounds page, which then feeds the list into ClientService.BulkDelete. +func (s *InboundService) EmailsByInbound(inboundId int) ([]string, error) { + inbound, err := s.GetInbound(inboundId) + if err != nil { + return nil, err + } + clients, err := s.GetClients(inbound) + if err != nil { + return nil, err + } + emails := make([]string, 0, len(clients)) + for _, c := range clients { + if e := strings.TrimSpace(c.Email); e != "" { + emails = append(emails, e) + } + } + return emails, nil +} + func (s *InboundService) DelDepletedClients(id int) (err error) { db := database.GetDB() tx := db.Begin() diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index 3b1bc86e..7c87152b 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -289,6 +289,9 @@ "resetConfirmContent": "يعيد عدادات الإرسال/الاستقبال لهذا الإدخال إلى 0.", "cloneConfirmTitle": "نسخ الإدخال \"{remark}\"؟", "cloneConfirmContent": "ينشئ نسخة بمنفذ جديد وقائمة عملاء فارغة.", + "delAllClients": "حذف جميع العملاء", + "delAllClientsConfirmTitle": "حذف جميع العملاء البالغ عددهم {count} من \"{remark}\"؟", + "delAllClientsConfirmContent": "يزيل كل عميل من هذا الإدخال ويحذف سجلات حركة المرور الخاصة بهم. يتم الاحتفاظ بالإدخال نفسه. لا يمكن التراجع عن هذا.", "exportLinksTitle": "تصدير روابط الإدخال", "exportSubsTitle": "تصدير روابط الاشتراك", "exportAllLinksTitle": "تصدير كل روابط الإدخالات", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index a726ed79..8bbc166d 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -295,6 +295,9 @@ "resetConfirmContent": "Resets up/down counters to 0 for this inbound.", "cloneConfirmTitle": "Clone inbound \"{remark}\"?", "cloneConfirmContent": "Creates a copy with a new port and an empty client list.", + "delAllClients": "Delete All Clients", + "delAllClientsConfirmTitle": "Delete all {count} clients from \"{remark}\"?", + "delAllClientsConfirmContent": "This removes every client from this inbound and drops their traffic records. The inbound itself is kept. This cannot be undone.", "exportLinksTitle": "Export inbound links", "exportSubsTitle": "Export subscription links", "exportAllLinksTitle": "Export all inbound links", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index d3ae2bfa..13ed0692 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -289,6 +289,9 @@ "resetConfirmContent": "Restablece los contadores de subida/bajada a 0 para este inbound.", "cloneConfirmTitle": "¿Clonar el inbound \"{remark}\"?", "cloneConfirmContent": "Crea una copia con un puerto nuevo y una lista de clientes vacía.", + "delAllClients": "Eliminar todos los clientes", + "delAllClientsConfirmTitle": "¿Eliminar los {count} clientes de \"{remark}\"?", + "delAllClientsConfirmContent": "Elimina todos los clientes de este inbound y sus registros de tráfico. El inbound se mantiene. Esto no se puede deshacer.", "exportLinksTitle": "Exportar enlaces del inbound", "exportSubsTitle": "Exportar enlaces de suscripción", "exportAllLinksTitle": "Exportar todos los enlaces de inbound", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index fa63d3af..bf3fa30f 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -290,6 +290,9 @@ "resetConfirmContent": "شمارنده‌های ارسال/دریافت این اینباند به صفر برمی‌گردد.", "cloneConfirmTitle": "اینباند «{remark}» کپی شود؟", "cloneConfirmContent": "یک نسخه با پورت جدید و لیست کلاینت خالی ساخته می‌شود.", + "delAllClients": "حذف همه کلاینت‌ها", + "delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟", + "delAllClientsConfirmContent": "تمام کلاینت‌های این اینباند به همراه رکوردهای ترافیک‌شان حذف می‌شوند. خود اینباند باقی می‌ماند. این عمل غیرقابل بازگشت است.", "exportLinksTitle": "خروجی لینک‌های اینباند", "exportSubsTitle": "خروجی لینک‌های ساب", "exportAllLinksTitle": "خروجی لینک‌های همه اینباندها", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index 988e2c8f..63292b9b 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -289,6 +289,9 @@ "resetConfirmContent": "Mengatur ulang counter unggah/unduh ke 0 untuk inbound ini.", "cloneConfirmTitle": "Klon inbound \"{remark}\"?", "cloneConfirmContent": "Membuat salinan dengan port baru dan daftar klien kosong.", + "delAllClients": "Hapus Semua Klien", + "delAllClientsConfirmTitle": "Hapus semua {count} klien dari \"{remark}\"?", + "delAllClientsConfirmContent": "Menghapus setiap klien dari inbound ini dan menghapus catatan trafiknya. Inbound itu sendiri dipertahankan. Tindakan ini tidak dapat dibatalkan.", "exportLinksTitle": "Ekspor tautan inbound", "exportSubsTitle": "Ekspor tautan langganan", "exportAllLinksTitle": "Ekspor semua tautan inbound", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 9aa7e47f..fb4d8d67 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -289,6 +289,9 @@ "resetConfirmContent": "このインバウンドの送受信カウンタを 0 にリセットします。", "cloneConfirmTitle": "インバウンド「{remark}」を複製しますか?", "cloneConfirmContent": "新しいポートと空のクライアント一覧でコピーを作成します。", + "delAllClients": "すべてのクライアントを削除", + "delAllClientsConfirmTitle": "「{remark}」から {count} 件のクライアントをすべて削除しますか?", + "delAllClientsConfirmContent": "このインバウンドからすべてのクライアントを削除し、トラフィックレコードも破棄します。インバウンド自体は保持されます。この操作は取り消せません。", "exportLinksTitle": "インバウンドリンクのエクスポート", "exportSubsTitle": "サブスクリプションリンクのエクスポート", "exportAllLinksTitle": "全インバウンドリンクのエクスポート", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 95a626c3..0f55cfbc 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -289,6 +289,9 @@ "resetConfirmContent": "Zera os contadores de envio/recebimento para este inbound.", "cloneConfirmTitle": "Clonar o inbound \"{remark}\"?", "cloneConfirmContent": "Cria uma cópia com uma nova porta e lista de clientes vazia.", + "delAllClients": "Excluir todos os clientes", + "delAllClientsConfirmTitle": "Excluir todos os {count} clientes de \"{remark}\"?", + "delAllClientsConfirmContent": "Remove todos os clientes deste inbound e descarta seus registros de tráfego. O inbound em si é mantido. Esta ação não pode ser desfeita.", "exportLinksTitle": "Exportar links do inbound", "exportSubsTitle": "Exportar links de assinatura", "exportAllLinksTitle": "Exportar todos os links de inbound", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index fc5f776e..97a460d1 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -289,6 +289,9 @@ "resetConfirmContent": "Сбрасывает счётчики отправки/получения этого подключения до 0.", "cloneConfirmTitle": "Клонировать подключение \"{remark}\"?", "cloneConfirmContent": "Создаёт копию с новым портом и пустым списком клиентов.", + "delAllClients": "Удалить всех клиентов", + "delAllClientsConfirmTitle": "Удалить всех {count} клиентов из \"{remark}\"?", + "delAllClientsConfirmContent": "Удаляет всех клиентов этого подключения и сбрасывает их записи трафика. Само подключение сохраняется. Это действие нельзя отменить.", "exportLinksTitle": "Экспортировать ссылки подключения", "exportSubsTitle": "Экспортировать ссылки подписки", "exportAllLinksTitle": "Экспортировать все ссылки подключений", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index 78873f02..cd52b784 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -289,6 +289,9 @@ "resetConfirmContent": "Bu inbound için gönderme/alma sayaçlarını 0'a sıfırlar.", "cloneConfirmTitle": "\"{remark}\" inbound klonlansın mı?", "cloneConfirmContent": "Yeni bir port ve boş istemci listesiyle bir kopya oluşturur.", + "delAllClients": "Tüm istemcileri sil", + "delAllClientsConfirmTitle": "\"{remark}\" içindeki {count} istemcinin tamamı silinsin mi?", + "delAllClientsConfirmContent": "Bu inbound'a ait tüm istemcileri ve trafik kayıtlarını siler. Inbound'un kendisi korunur. Bu işlem geri alınamaz.", "exportLinksTitle": "Inbound bağlantılarını dışa aktar", "exportSubsTitle": "Abonelik bağlantılarını dışa aktar", "exportAllLinksTitle": "Tüm inbound bağlantılarını dışa aktar", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index af228614..2203acbf 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -289,6 +289,9 @@ "resetConfirmContent": "Скидає лічильники відправки/отримання цього вхідного до 0.", "cloneConfirmTitle": "Клонувати вхідні \"{remark}\"?", "cloneConfirmContent": "Створює копію з новим портом і порожнім списком клієнтів.", + "delAllClients": "Видалити всіх клієнтів", + "delAllClientsConfirmTitle": "Видалити всіх {count} клієнтів із \"{remark}\"?", + "delAllClientsConfirmContent": "Видаляє всіх клієнтів цього вхідного й скидає їхні записи трафіку. Сам вхідний зберігається. Цю дію не можна скасувати.", "exportLinksTitle": "Експортувати посилання вхідних", "exportSubsTitle": "Експортувати посилання підписок", "exportAllLinksTitle": "Експортувати всі посилання вхідних", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index 9f31ebca..fde67e6f 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -289,6 +289,9 @@ "resetConfirmContent": "Đặt lại bộ đếm lên/xuống về 0 cho inbound này.", "cloneConfirmTitle": "Sao chép inbound \"{remark}\"?", "cloneConfirmContent": "Tạo bản sao với cổng mới và danh sách khách hàng trống.", + "delAllClients": "Xóa tất cả khách hàng", + "delAllClientsConfirmTitle": "Xóa toàn bộ {count} khách hàng khỏi \"{remark}\"?", + "delAllClientsConfirmContent": "Xóa mọi khách hàng khỏi inbound này và hủy bản ghi lưu lượng của họ. Bản thân inbound vẫn được giữ lại. Hành động này không thể hoàn tác.", "exportLinksTitle": "Xuất liên kết inbound", "exportSubsTitle": "Xuất liên kết đăng ký", "exportAllLinksTitle": "Xuất tất cả liên kết inbound", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 9605096c..5686e18d 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -289,6 +289,9 @@ "resetConfirmContent": "将此入站的上/下行计数器清零。", "cloneConfirmTitle": "克隆入站 \"{remark}\"?", "cloneConfirmContent": "使用新端口和空客户端列表创建副本。", + "delAllClients": "删除所有客户端", + "delAllClientsConfirmTitle": "从 \"{remark}\" 中删除全部 {count} 个客户端?", + "delAllClientsConfirmContent": "从此入站中移除每个客户端并丢弃其流量记录。入站本身将保留。此操作无法撤销。", "exportLinksTitle": "导出入站链接", "exportSubsTitle": "导出订阅链接", "exportAllLinksTitle": "导出所有入站链接", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index 20a51677..486d8243 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -289,6 +289,9 @@ "resetConfirmContent": "將此入站的上/下行計數器歸零。", "cloneConfirmTitle": "複製入站「{remark}」?", "cloneConfirmContent": "使用新連接埠和空客戶端清單建立副本。", + "delAllClients": "刪除所有客戶端", + "delAllClientsConfirmTitle": "從「{remark}」中刪除全部 {count} 個客戶端?", + "delAllClientsConfirmContent": "從此入站中移除每個客戶端並捨棄其流量記錄。入站本身將保留。此操作無法復原。", "exportLinksTitle": "匯出入站連結", "exportSubsTitle": "匯出訂閱連結", "exportAllLinksTitle": "匯出所有入站連結",