瀏覽代碼

feat(inbounds): add multi-select and bulk delete

Mirror the clients page: checkbox selection on the desktop table and on
mobile cards, with a danger Delete button in the toolbar that removes all
selected inbounds in one call.

Backend adds POST /panel/api/inbounds/bulkDel, which loops the existing
DelInbound per id (xray restarts at most once) and returns {deleted,
skipped}. Frontend shows a confirm modal plus a result toast, clears the
selection on success, adds bulk-delete i18n keys across all 13 languages,
and documents the endpoint in the in-panel API docs.
MHSanaei 7 小時之前
父節點
當前提交
cf50952921

+ 59 - 0
frontend/public/openapi.json

@@ -615,6 +615,65 @@
         }
       }
     },
+    "/panel/api/inbounds/bulkDel": {
+      "post": {
+        "tags": [
+          "Inbounds"
+        ],
+        "summary": "Delete many inbounds in one call. Processes the list sequentially; failures are reported per id and the rest still proceed. Restarts xray at most once.",
+        "operationId": "post_panel_api_inbounds_bulkDel",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "ids": [
+                  1,
+                  2,
+                  3
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "deleted": 2,
+                    "skipped": [
+                      {
+                        "id": 3,
+                        "reason": "..."
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/inbounds/update/{id}": {
       "post": {
         "tags": [

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -149,6 +149,13 @@ export const sections: readonly Section[] = [
           { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
         ],
       },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/bulkDel',
+        summary: 'Delete many inbounds in one call. Processes the list sequentially; failures are reported per id and the rest still proceed. Restarts xray at most once.',
+        body: '{\n  "ids": [1, 2, 3]\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "deleted": 2,\n    "skipped": [\n      { "id": 3, "reason": "..." }\n    ]\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/inbounds/update/:id',

+ 31 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -357,6 +357,36 @@ export default function InboundsPage() {
     });
   }, [modal, refresh, t]);
 
+  const confirmBulkDelete = useCallback((ids: number[]) => new Promise<boolean>((resolve) => {
+    if (ids.length === 0) {
+      resolve(false);
+      return;
+    }
+    modal.confirm({
+      title: t('pages.inbounds.bulkDeleteConfirmTitle', { count: ids.length }),
+      content: t('pages.inbounds.bulkDeleteConfirmContent'),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await HttpUtil.post('/panel/api/inbounds/bulkDel', { ids }, { headers: { 'Content-Type': 'application/json' } });
+        const obj = (msg?.obj ?? {}) as { deleted?: number; skipped?: { id: number; reason: string }[] };
+        const ok = obj.deleted ?? 0;
+        const skipped = obj.skipped ?? [];
+        if (msg?.success && skipped.length === 0) {
+          messageApi.success(t('pages.inbounds.toasts.bulkDeleted', { count: ok }));
+        } else {
+          const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
+          const base = t('pages.inbounds.toasts.bulkDeletedMixed', { ok, failed: skipped.length });
+          messageApi.warning(firstError ? `${base} — ${firstError}` : base);
+        }
+        await refresh();
+        resolve(true);
+      },
+      onCancel: () => resolve(false),
+    });
+  }), [modal, refresh, t, messageApi]);
+
   const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
     modal.confirm({
       title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
@@ -567,6 +597,7 @@ export default function InboundsPage() {
                       onAddInbound={onAddInbound}
                       onGeneralAction={onGeneralAction}
                       onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
+                      onBulkDelete={confirmBulkDelete}
                     />
                   </Col>
                 </Row>

+ 20 - 0
frontend/src/pages/inbounds/list/InboundList.css

@@ -75,6 +75,26 @@
   gap: 8px;
 }
 
+.inbound-card.is-selected {
+  border-color: var(--ant-color-primary);
+  background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
+}
+
+.card-bulk-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 4px 8px;
+}
+
+.bulk-count {
+  font-size: 12px;
+  background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
+  color: var(--ant-color-primary);
+  padding: 1px 8px;
+  border-radius: 10px;
+}
+
 .card-head {
   display: flex;
   align-items: center;

+ 61 - 4
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -1,12 +1,14 @@
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useMemo, useState, type Key } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Button,
   Card,
+  Checkbox,
   Dropdown,
   Space,
   Switch,
   Table,
+  Tag,
   Tooltip,
   type MenuProps,
 } from 'antd';
@@ -18,6 +20,7 @@ import {
   ImportOutlined,
   ReloadOutlined,
   InfoCircleOutlined,
+  DeleteOutlined,
 } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
@@ -43,11 +46,13 @@ export default function InboundList({
   onAddInbound,
   onGeneralAction,
   onRowAction,
+  onBulkDelete,
 }: InboundListProps) {
   const { t } = useTranslation();
   const [sortKey, setSortKey] = useState<SortKey | null>(null);
   const [sortOrder, setSortOrder] = useState<SortOrder>(null);
   const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
+  const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
 
   const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
     const previous = dbInbound.enable;
@@ -75,6 +80,26 @@ export default function InboundList({
     [dbInbounds],
   );
 
+  const toggleSelect = useCallback((id: number, checked: boolean) => {
+    setSelectedRowKeys((prev) => {
+      const next = new Set(prev);
+      if (checked) next.add(id); else next.delete(id);
+      return Array.from(next);
+    });
+  }, []);
+
+  const selectAll = useCallback((checked: boolean) => {
+    setSelectedRowKeys(checked ? sortedInbounds.map((i) => i.id) : []);
+  }, [sortedInbounds]);
+
+  const allSelected = sortedInbounds.length > 0 && selectedRowKeys.length === sortedInbounds.length;
+  const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < sortedInbounds.length;
+
+  const handleBulkDelete = useCallback(async () => {
+    const ok = await onBulkDelete(selectedRowKeys);
+    if (ok) setSelectedRowKeys([]);
+  }, [onBulkDelete, selectedRowKeys]);
+
   const columns = useInboundColumns({
     hasAnyRemark,
     hasActiveNode,
@@ -119,6 +144,16 @@ export default function InboundList({
               {!isMobile && t('pages.inbounds.generalActions')}
             </Button>
           </Dropdown>
+          {selectedRowKeys.length > 0 && (
+            <>
+              <Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
+                {t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
+              </Tag>
+              <Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete}>
+                {!isMobile && t('delete')}
+              </Button>
+            </>
+          )}
         </Space>
       )}
     >
@@ -131,9 +166,26 @@ export default function InboundList({
                 <div>{t('noData')}</div>
               </div>
             ) : (
-              sortedInbounds.map((record) => (
-                <div key={record.id} className="inbound-card">
+              <>
+              <div className="card-bulk-bar">
+                <Checkbox
+                  checked={allSelected}
+                  indeterminate={someSelected}
+                  onChange={(e) => selectAll(e.target.checked)}
+                >
+                  {t('pages.inbounds.selectAll')}
+                </Checkbox>
+                {selectedRowKeys.length > 0 && (
+                  <span className="bulk-count">{selectedRowKeys.length}</span>
+                )}
+              </div>
+              {sortedInbounds.map((record) => (
+                <div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
                   <div className="card-head">
+                    <Checkbox
+                      checked={selectedRowKeys.includes(record.id)}
+                      onChange={(e) => toggleSelect(record.id, e.target.checked)}
+                    />
                     <span className="card-id">#{record.id}</span>
                     <span className="tag-name">{record.remark}</span>
                     <div className="card-actions" onClick={(e) => e.stopPropagation()}>
@@ -158,7 +210,8 @@ export default function InboundList({
                     </div>
                   </div>
                 </div>
-              ))
+              ))}
+              </>
             )}
           </div>
         ) : (
@@ -166,6 +219,10 @@ export default function InboundList({
             columns={columns}
             dataSource={sortedInbounds}
             rowKey={(r) => r.id}
+            rowSelection={{
+              selectedRowKeys,
+              onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
+            }}
             pagination={paginationFor(sortedInbounds)}
             scroll={{ x: 1000 }}
             style={{ marginTop: 10 }}

+ 1 - 0
frontend/src/pages/inbounds/list/types.ts

@@ -72,6 +72,7 @@ export interface InboundListProps {
   onAddInbound: () => void;
   onGeneralAction: (key: GeneralAction) => void;
   onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
+  onBulkDelete: (ids: number[]) => Promise<boolean>;
 }
 
 export type SortKey =

+ 27 - 0
web/controller/inbound.go

@@ -69,6 +69,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
 
 	g.POST("/add", a.addInbound)
 	g.POST("/del/:id", a.delInbound)
+	g.POST("/bulkDel", a.bulkDelInbounds)
 	g.POST("/update/:id", a.updateInbound)
 	g.POST("/setEnable/:id", a.setInboundEnable)
 	g.POST("/:id/resetTraffic", a.resetInboundTraffic)
@@ -179,6 +180,32 @@ func (a *InboundController) delInbound(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+type bulkDelInboundsRequest struct {
+	Ids []int `json:"ids"`
+}
+
+// bulkDelInbounds deletes several inbounds in one call. Failures are
+// reported per id and the rest still proceed; xray restarts at most once.
+func (a *InboundController) bulkDelInbounds(c *gin.Context) {
+	var req bulkDelInboundsRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	result, needRestart, err := a.inboundService.DelInbounds(req.Ids)
+	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()
+}
+
 // updateInbound updates an existing inbound configuration.
 func (a *InboundController) updateInbound(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))

+ 31 - 0
web/service/inbound.go

@@ -617,6 +617,37 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 	return needRestart, db.Delete(model.Inbound{}, id).Error
 }
 
+type BulkDelInboundResult struct {
+	Deleted int                    `json:"deleted"`
+	Skipped []BulkDelInboundReport `json:"skipped,omitempty"`
+}
+
+type BulkDelInboundReport struct {
+	Id     int    `json:"id"`
+	Reason string `json:"reason"`
+}
+
+// DelInbounds removes every inbound in the list, reusing the single-delete
+// path per id. Failures are recorded in Skipped and processing continues for
+// the rest; the aggregated needRestart is returned so the caller restarts
+// xray at most once.
+func (s *InboundService) DelInbounds(ids []int) (BulkDelInboundResult, bool, error) {
+	result := BulkDelInboundResult{}
+	needRestart := false
+	for _, id := range ids {
+		r, err := s.DelInbound(id)
+		if err != nil {
+			result.Skipped = append(result.Skipped, BulkDelInboundReport{Id: id, Reason: err.Error()})
+			continue
+		}
+		result.Deleted++
+		if r {
+			needRestart = true
+		}
+	}
+	return result, needRestart, nil
+}
+
 func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
 	db := database.GetDB()
 	inbound := &model.Inbound{}

+ 6 - 0
web/translation/ar-EG.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "سيؤدي هذا إلى إزالة الإدخال وجميع عملائه. لا يمكن التراجع.",
       "resetConfirmTitle": "إعادة تعيين ترافيك \"{remark}\"؟",
       "resetConfirmContent": "يعيد عدادات الإرسال/الاستقبال لهذا الإدخال إلى 0.",
+      "selectedCount": "{count} محدد",
+      "selectAll": "تحديد الكل",
+      "bulkDeleteConfirmTitle": "حذف {count} إدخال؟",
+      "bulkDeleteConfirmContent": "سيؤدي هذا إلى إزالة الإدخالات المحددة وجميع عملائها. لا يمكن التراجع.",
       "cloneConfirmTitle": "نسخ الإدخال \"{remark}\"؟",
       "cloneConfirmContent": "ينشئ نسخة بمنفذ جديد وقائمة عملاء فارغة.",
       "delAllClients": "حذف جميع العملاء",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "تم تحديث الواردات بنجاح",
         "inboundUpdateSuccess": "تم تحديث الوارد بنجاح",
         "inboundCreateSuccess": "تم إنشاء الوارد بنجاح",
+        "bulkDeleted": "تم حذف {count} إدخال",
+        "bulkDeletedMixed": "تم حذف {ok}, وفشل {failed}",
         "inboundDeleteSuccess": "تم حذف الوارد بنجاح",
         "inboundClientAddSuccess": "تمت إضافة عميل(عملاء) وارد",
         "inboundClientDeleteSuccess": "تم حذف عميل وارد",

+ 6 - 0
web/translation/en-US.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "This removes the inbound and all its clients. This cannot be undone.",
       "resetConfirmTitle": "Reset traffic for \"{remark}\"?",
       "resetConfirmContent": "Resets up/down counters to 0 for this inbound.",
+      "selectedCount": "{count} selected",
+      "selectAll": "Select all",
+      "bulkDeleteConfirmTitle": "Delete {count} inbounds?",
+      "bulkDeleteConfirmContent": "This removes the selected inbounds and all their clients. This cannot be undone.",
       "cloneConfirmTitle": "Clone inbound \"{remark}\"?",
       "cloneConfirmContent": "Creates a copy with a new port and an empty client list.",
       "delAllClients": "Delete All Clients",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "Inbounds have been successfully updated.",
         "inboundUpdateSuccess": "Inbound has been successfully updated.",
         "inboundCreateSuccess": "Inbound has been successfully created.",
+        "bulkDeleted": "{count} inbounds deleted",
+        "bulkDeletedMixed": "{ok} deleted, {failed} failed",
         "inboundDeleteSuccess": "Inbound has been successfully deleted.",
         "inboundClientAddSuccess": "Inbound client(s) have been added.",
         "inboundClientDeleteSuccess": "Inbound client has been deleted.",

+ 6 - 0
web/translation/es-ES.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "Esto elimina el inbound y todos sus clientes. No se puede deshacer.",
       "resetConfirmTitle": "¿Restablecer el tráfico de \"{remark}\"?",
       "resetConfirmContent": "Restablece los contadores de subida/bajada a 0 para este inbound.",
+      "selectedCount": "{count} seleccionado(s)",
+      "selectAll": "Seleccionar todo",
+      "bulkDeleteConfirmTitle": "¿Eliminar {count} inbounds?",
+      "bulkDeleteConfirmContent": "Esto elimina los inbounds seleccionados y todos sus clientes. No se puede deshacer.",
       "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",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "Entradas actualizadas correctamente",
         "inboundUpdateSuccess": "Entrada actualizada correctamente",
         "inboundCreateSuccess": "Entrada creada correctamente",
+        "bulkDeleted": "{count} inbounds eliminados",
+        "bulkDeletedMixed": "{ok} eliminados, {failed} fallidos",
         "inboundDeleteSuccess": "Entrada eliminada correctamente",
         "inboundClientAddSuccess": "Cliente(s) de entrada añadido(s)",
         "inboundClientDeleteSuccess": "Cliente de entrada eliminado",

+ 6 - 0
web/translation/fa-IR.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "این اینباند و تمام کلاینت‌های آن حذف می‌شود. این عمل غیرقابل بازگشت است.",
       "resetConfirmTitle": "ترافیک اینباند «{remark}» صفر شود؟",
       "resetConfirmContent": "شمارنده‌های ارسال/دریافت این اینباند به صفر برمی‌گردد.",
+      "selectedCount": "{count} انتخاب‌شده",
+      "selectAll": "انتخاب همه",
+      "bulkDeleteConfirmTitle": "حذف {count} اینباند؟",
+      "bulkDeleteConfirmContent": "اینباندهای انتخاب‌شده و تمام کلاینت‌های آن‌ها حذف می‌شوند. این عمل غیرقابل بازگشت است.",
       "cloneConfirmTitle": "اینباند «{remark}» کپی شود؟",
       "cloneConfirmContent": "یک نسخه با پورت جدید و لیست کلاینت خالی ساخته می‌شود.",
       "delAllClients": "حذف همه کلاینت‌ها",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "ورودی‌ها با موفقیت به‌روزرسانی شدند",
         "inboundUpdateSuccess": "ورودی با موفقیت به‌روزرسانی شد",
         "inboundCreateSuccess": "ورودی با موفقیت ایجاد شد",
+        "bulkDeleted": "{count} اینباند حذف شد",
+        "bulkDeletedMixed": "{ok} حذف، {failed} ناموفق",
         "inboundDeleteSuccess": "ورودی با موفقیت حذف شد",
         "inboundClientAddSuccess": "کلاینت(های) ورودی اضافه شدند",
         "inboundClientDeleteSuccess": "کلاینت ورودی حذف شد",

+ 6 - 0
web/translation/id-ID.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "Tindakan ini menghapus inbound beserta semua kliennya. Tidak dapat dibatalkan.",
       "resetConfirmTitle": "Reset trafik \"{remark}\"?",
       "resetConfirmContent": "Mengatur ulang counter unggah/unduh ke 0 untuk inbound ini.",
+      "selectedCount": "{count} dipilih",
+      "selectAll": "Pilih semua",
+      "bulkDeleteConfirmTitle": "Hapus {count} inbound?",
+      "bulkDeleteConfirmContent": "Tindakan ini menghapus inbound yang dipilih beserta semua kliennya. Tidak dapat dibatalkan.",
       "cloneConfirmTitle": "Klon inbound \"{remark}\"?",
       "cloneConfirmContent": "Membuat salinan dengan port baru dan daftar klien kosong.",
       "delAllClients": "Hapus Semua Klien",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "Inbound berhasil diperbarui",
         "inboundUpdateSuccess": "Inbound berhasil diperbarui",
         "inboundCreateSuccess": "Inbound berhasil dibuat",
+        "bulkDeleted": "{count} inbound dihapus",
+        "bulkDeletedMixed": "{ok} dihapus, {failed} gagal",
         "inboundDeleteSuccess": "Inbound berhasil dihapus",
         "inboundClientAddSuccess": "Klien inbound telah ditambahkan",
         "inboundClientDeleteSuccess": "Klien inbound telah dihapus",

+ 6 - 0
web/translation/ja-JP.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "インバウンドと関連付けされたすべてのクライアントを削除します。元に戻せません。",
       "resetConfirmTitle": "「{remark}」のトラフィックをリセットしますか?",
       "resetConfirmContent": "このインバウンドの送受信カウンタを 0 にリセットします。",
+      "selectedCount": "{count} 選択中",
+      "selectAll": "すべて選択",
+      "bulkDeleteConfirmTitle": "{count} 件のインバウンドを削除しますか?",
+      "bulkDeleteConfirmContent": "選択したインバウンドと関連付けされたすべてのクライアントを削除します。元に戻せません。",
       "cloneConfirmTitle": "インバウンド「{remark}」を複製しますか?",
       "cloneConfirmContent": "新しいポートと空のクライアント一覧でコピーを作成します。",
       "delAllClients": "すべてのクライアントを削除",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "インバウンドが正常に更新されました",
         "inboundUpdateSuccess": "インバウンドが正常に更新されました",
         "inboundCreateSuccess": "インバウンドが正常に作成されました",
+        "bulkDeleted": "{count} 件のインバウンドを削除しました",
+        "bulkDeletedMixed": "{ok} 件削除、{failed} 件失敗",
         "inboundDeleteSuccess": "インバウンドが正常に削除されました",
         "inboundClientAddSuccess": "インバウンドクライアントが追加されました",
         "inboundClientDeleteSuccess": "インバウンドクライアントが削除されました",

+ 6 - 0
web/translation/pt-BR.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "Isto remove o inbound e todos os seus clientes. Não é possível desfazer.",
       "resetConfirmTitle": "Redefinir o tráfego de \"{remark}\"?",
       "resetConfirmContent": "Zera os contadores de envio/recebimento para este inbound.",
+      "selectedCount": "{count} selecionado(s)",
+      "selectAll": "Selecionar tudo",
+      "bulkDeleteConfirmTitle": "Excluir {count} inbounds?",
+      "bulkDeleteConfirmContent": "Isto remove os inbounds selecionados e todos os seus clientes. Não é possível desfazer.",
       "cloneConfirmTitle": "Clonar o inbound \"{remark}\"?",
       "cloneConfirmContent": "Cria uma cópia com uma nova porta e lista de clientes vazia.",
       "delAllClients": "Excluir todos os clientes",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "Entradas atualizadas com sucesso",
         "inboundUpdateSuccess": "Entrada atualizada com sucesso",
         "inboundCreateSuccess": "Entrada criada com sucesso",
+        "bulkDeleted": "{count} inbounds excluídos",
+        "bulkDeletedMixed": "{ok} excluídos, {failed} com falha",
         "inboundDeleteSuccess": "Entrada excluída com sucesso",
         "inboundClientAddSuccess": "Cliente(s) de entrada adicionado(s)",
         "inboundClientDeleteSuccess": "Cliente de entrada excluído",

+ 6 - 0
web/translation/ru-RU.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "Подключение и все его клиенты будут удалены. Это действие нельзя отменить.",
       "resetConfirmTitle": "Сбросить трафик \"{remark}\"?",
       "resetConfirmContent": "Сбрасывает счётчики отправки/получения этого подключения до 0.",
+      "selectedCount": "{count} выбрано",
+      "selectAll": "Выбрать всё",
+      "bulkDeleteConfirmTitle": "Удалить {count} подключений?",
+      "bulkDeleteConfirmContent": "Выбранные подключения и все их клиенты будут удалены. Это действие нельзя отменить.",
       "cloneConfirmTitle": "Клонировать подключение \"{remark}\"?",
       "cloneConfirmContent": "Создаёт копию с новым портом и пустым списком клиентов.",
       "delAllClients": "Удалить всех клиентов",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "Подключения успешно обновлены",
         "inboundUpdateSuccess": "Подключение успешно обновлено",
         "inboundCreateSuccess": "Подключение успешно создано",
+        "bulkDeleted": "Удалено подключений: {count}",
+        "bulkDeletedMixed": "Удалено: {ok}, не удалось: {failed}",
         "inboundDeleteSuccess": "Подключение успешно удалено",
         "inboundClientAddSuccess": "Клиент(ы) подключения добавлен(ы)",
         "inboundClientDeleteSuccess": "Клиент подключения удалён",

+ 6 - 0
web/translation/tr-TR.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "Bu işlem inbound'u ve tüm istemcilerini siler. Geri alınamaz.",
       "resetConfirmTitle": "\"{remark}\" trafiği sıfırlansın mı?",
       "resetConfirmContent": "Bu inbound için gönderme/alma sayaçlarını 0'a sıfırlar.",
+      "selectedCount": "{count} seçildi",
+      "selectAll": "Tümünü seç",
+      "bulkDeleteConfirmTitle": "{count} inbound silinsin mi?",
+      "bulkDeleteConfirmContent": "Bu işlem seçili inbound'ları ve tüm istemcilerini siler. Geri alınamaz.",
       "cloneConfirmTitle": "\"{remark}\" inbound klonlansın mı?",
       "cloneConfirmContent": "Yeni bir port ve boş istemci listesiyle bir kopya oluşturur.",
       "delAllClients": "Tüm istemcileri sil",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "Gelen bağlantılar başarıyla güncellendi",
         "inboundUpdateSuccess": "Gelen bağlantı başarıyla güncellendi",
         "inboundCreateSuccess": "Gelen bağlantı başarıyla oluşturuldu",
+        "bulkDeleted": "{count} inbound silindi",
+        "bulkDeletedMixed": "{ok} silindi, {failed} başarısız",
         "inboundDeleteSuccess": "Gelen bağlantı başarıyla silindi",
         "inboundClientAddSuccess": "Gelen bağlantı istemci(leri) eklendi",
         "inboundClientDeleteSuccess": "Gelen bağlantı istemcisi silindi",

+ 6 - 0
web/translation/uk-UA.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "Це видалить вхідні та всіх його клієнтів. Цю дію неможливо скасувати.",
       "resetConfirmTitle": "Скинути трафік \"{remark}\"?",
       "resetConfirmContent": "Скидає лічильники відправки/отримання цього вхідного до 0.",
+      "selectedCount": "Обрано {count}",
+      "selectAll": "Вибрати все",
+      "bulkDeleteConfirmTitle": "Видалити {count} вхідних підключень?",
+      "bulkDeleteConfirmContent": "Будуть видалені вибрані вхідні підключення та всі їхні клієнти. Цю дію неможливо скасувати.",
       "cloneConfirmTitle": "Клонувати вхідні \"{remark}\"?",
       "cloneConfirmContent": "Створює копію з новим портом і порожнім списком клієнтів.",
       "delAllClients": "Видалити всіх клієнтів",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "Вхідні підключення успішно оновлено",
         "inboundUpdateSuccess": "Вхідне підключення успішно оновлено",
         "inboundCreateSuccess": "Вхідне підключення успішно створено",
+        "bulkDeleted": "Видалено підключень: {count}",
+        "bulkDeletedMixed": "Видалено: {ok}, не вдалось: {failed}",
         "inboundDeleteSuccess": "Вхідне підключення успішно видалено",
         "inboundClientAddSuccess": "Клієнт(и) вхідного підключення додано",
         "inboundClientDeleteSuccess": "Клієнта вхідного підключення видалено",

+ 6 - 0
web/translation/vi-VN.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "Hành động này xóa inbound và toàn bộ khách hàng của nó. Không thể hoàn tác.",
       "resetConfirmTitle": "Đặt lại lưu lượng của \"{remark}\"?",
       "resetConfirmContent": "Đặt lại bộ đếm lên/xuống về 0 cho inbound này.",
+      "selectedCount": "Đã chọn {count}",
+      "selectAll": "Chọn tất cả",
+      "bulkDeleteConfirmTitle": "Xóa {count} inbound?",
+      "bulkDeleteConfirmContent": "Hành động này xóa các inbound đã chọn và toàn bộ khách hàng của chúng. Không thể hoàn tác.",
       "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",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "Đã cập nhật thành công các kết nối inbound",
         "inboundUpdateSuccess": "Đã cập nhật thành công kết nối inbound",
         "inboundCreateSuccess": "Đã tạo thành công kết nối inbound",
+        "bulkDeleted": "Đã xóa {count} inbound",
+        "bulkDeletedMixed": "Đã xóa {ok}, thất bại {failed}",
         "inboundDeleteSuccess": "Đã xóa thành công kết nối inbound",
         "inboundClientAddSuccess": "Đã thêm client inbound",
         "inboundClientDeleteSuccess": "Đã xóa client inbound",

+ 6 - 0
web/translation/zh-CN.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "将删除此入站及其所有客户端。该操作不可撤销。",
       "resetConfirmTitle": "重置 \"{remark}\" 的流量?",
       "resetConfirmContent": "将此入站的上/下行计数器清零。",
+      "selectedCount": "已选 {count} 项",
+      "selectAll": "全选",
+      "bulkDeleteConfirmTitle": "删除 {count} 个入站?",
+      "bulkDeleteConfirmContent": "将删除所选入站及其所有客户端。该操作不可撤销。",
       "cloneConfirmTitle": "克隆入站 \"{remark}\"?",
       "cloneConfirmContent": "使用新端口和空客户端列表创建副本。",
       "delAllClients": "删除所有客户端",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "入站连接已成功更新",
         "inboundUpdateSuccess": "入站连接已成功更新",
         "inboundCreateSuccess": "入站连接已成功创建",
+        "bulkDeleted": "已删除 {count} 个入站",
+        "bulkDeletedMixed": "已删除 {ok} 个,失败 {failed} 个",
         "inboundDeleteSuccess": "入站连接已成功删除",
         "inboundClientAddSuccess": "已添加入站客户端",
         "inboundClientDeleteSuccess": "入站客户端已删除",

+ 6 - 0
web/translation/zh-TW.json

@@ -296,6 +296,10 @@
       "deleteConfirmContent": "將刪除此入站及其所有客戶端。此操作無法復原。",
       "resetConfirmTitle": "重置「{remark}」的流量?",
       "resetConfirmContent": "將此入站的上/下行計數器歸零。",
+      "selectedCount": "已選 {count} 項",
+      "selectAll": "全選",
+      "bulkDeleteConfirmTitle": "刪除 {count} 個入站?",
+      "bulkDeleteConfirmContent": "將刪除所選入站及其所有客戶端。此操作無法復原。",
       "cloneConfirmTitle": "複製入站「{remark}」?",
       "cloneConfirmContent": "使用新連接埠和空客戶端清單建立副本。",
       "delAllClients": "刪除所有客戶端",
@@ -421,6 +425,8 @@
         "inboundsUpdateSuccess": "入站連接已成功更新",
         "inboundUpdateSuccess": "入站連接已成功更新",
         "inboundCreateSuccess": "入站連接已成功建立",
+        "bulkDeleted": "已刪除 {count} 個入站",
+        "bulkDeletedMixed": "已刪除 {ok} 個,失敗 {failed} 個",
         "inboundDeleteSuccess": "入站連接已成功刪除",
         "inboundClientAddSuccess": "已新增入站客戶端",
         "inboundClientDeleteSuccess": "入站客戶端已刪除",