瀏覽代碼

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.
MHSanaei 1 天之前
父節點
當前提交
e64e998194

+ 116 - 0
frontend/public/openapi.json

@@ -5572,6 +5572,122 @@
         }
       }
     },
+    "/panel/api/clients/bulkEnable": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "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.",
+        "operationId": "post_panel_api_clients_bulkEnable",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "changed": 2,
+                    "skipped": [
+                      {
+                        "email": "carol",
+                        "reason": "client not found"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/bulkDisable": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "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.",
+        "operationId": "post_panel_api_clients_bulkDisable",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "changed": 2,
+                    "skipped": [
+                      {
+                        "email": "carol",
+                        "reason": "client not found"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/bulkDel": {
       "post": {
         "tags": [

+ 21 - 0
frontend/src/hooks/useClients.ts

@@ -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 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -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
frontend/src/pages/clients/ClientsPage.tsx

@@ -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 - 0
frontend/src/schemas/client.ts

@@ -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>;

+ 32 - 0
internal/web/controller/client.go

@@ -64,6 +64,8 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/delDepleted", a.delDepleted)
 	g.POST("/bulkAdjust", a.bulkAdjust)
+	g.POST("/bulkEnable", a.bulkEnable)
+	g.POST("/bulkDisable", a.bulkDisable)
 	g.POST("/bulkDel", a.bulkDelete)
 	g.POST("/bulkCreate", a.bulkCreate)
 	g.POST("/bulkAttach", a.bulkAttach)
@@ -338,6 +340,36 @@ func (a *ClientController) bulkDelete(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+type bulkEnableRequest struct {
+	Emails []string `json:"emails"`
+}
+
+func (a *ClientController) bulkEnable(c *gin.Context) {
+	a.bulkSetEnable(c, true)
+}
+
+func (a *ClientController) bulkDisable(c *gin.Context) {
+	a.bulkSetEnable(c, false)
+}
+
+func (a *ClientController) bulkSetEnable(c *gin.Context, enable bool) {
+	var req bulkEnableRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	result, needRestart, err := a.clientService.BulkSetEnable(&a.inboundService, req.Emails, enable)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, result, nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
 func (a *ClientController) bulkCreate(c *gin.Context) {
 	var payloads []service.ClientCreatePayload
 	if err := c.ShouldBindJSON(&payloads); err != nil {

+ 297 - 0
internal/web/service/client_bulk.go

@@ -1291,3 +1291,300 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro
 	}
 	return res.Deleted, needRestart, nil
 }
+
+type BulkSetEnableResult struct {
+	Changed int                   `json:"changed"`
+	Skipped []BulkSetEnableReport `json:"skipped,omitempty"`
+}
+
+type BulkSetEnableReport struct {
+	Email  string `json:"email"`
+	Reason string `json:"reason"`
+}
+
+func (s *ClientService) BulkSetEnable(inboundSvc *InboundService, emails []string, enable bool) (BulkSetEnableResult, bool, error) {
+	result := BulkSetEnableResult{}
+
+	seen := map[string]struct{}{}
+	cleanEmails := make([]string, 0, len(emails))
+	for _, e := range emails {
+		e = strings.TrimSpace(e)
+		if e == "" {
+			continue
+		}
+		if _, ok := seen[e]; ok {
+			continue
+		}
+		seen[e] = struct{}{}
+		cleanEmails = append(cleanEmails, e)
+	}
+	if len(cleanEmails) == 0 {
+		return result, false, nil
+	}
+
+	db := database.GetDB()
+
+	var records []model.ClientRecord
+	for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+		var rows []model.ClientRecord
+		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+			return result, false, err
+		}
+		records = append(records, rows...)
+	}
+	recordsByEmail := make(map[string]*model.ClientRecord, len(records))
+	for i := range records {
+		recordsByEmail[records[i].Email] = &records[i]
+	}
+
+	skippedReasons := map[string]string{}
+	for _, email := range cleanEmails {
+		if _, ok := recordsByEmail[email]; !ok {
+			skippedReasons[email] = "client not found"
+		}
+	}
+
+	clientIds := make([]int, 0, len(recordsByEmail))
+	recordIdToEmail := make(map[int]string, len(recordsByEmail))
+	for _, r := range recordsByEmail {
+		clientIds = append(clientIds, r.Id)
+		recordIdToEmail[r.Id] = r.Email
+	}
+
+	emailsByInbound := map[int][]string{}
+	if len(clientIds) > 0 {
+		var mappings []model.ClientInbound
+		for _, batch := range chunkInts(clientIds, sqlInChunk) {
+			var rows []model.ClientInbound
+			if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
+				return result, false, err
+			}
+			mappings = append(mappings, rows...)
+		}
+		for _, m := range mappings {
+			email, ok := recordIdToEmail[m.ClientId]
+			if !ok {
+				continue
+			}
+			emailsByInbound[m.InboundId] = append(emailsByInbound[m.InboundId], email)
+		}
+	}
+
+	needRestart := false
+	for inboundId, ibEmails := range emailsByInbound {
+		ibRes := s.bulkSetEnableInboundClients(inboundSvc, inboundId, ibEmails, enable)
+		if ibRes.needRestart {
+			needRestart = true
+		}
+		for email, reason := range ibRes.perEmailSkipped {
+			if _, already := skippedReasons[email]; !already {
+				skippedReasons[email] = reason
+			}
+		}
+	}
+
+	successEmails := make([]string, 0, len(recordsByEmail))
+	for email := range recordsByEmail {
+		if _, skipped := skippedReasons[email]; skipped {
+			continue
+		}
+		successEmails = append(successEmails, email)
+	}
+
+	if len(successEmails) > 0 {
+		now := time.Now().UnixMilli()
+		if err := runSerializedTx(func(tx *gorm.DB) error {
+			for _, batch := range chunkStrings(successEmails, sqlInChunk) {
+				if e := tx.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("enable", enable).Error; e != nil {
+					return e
+				}
+				if e := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).
+					Updates(map[string]any{"enable": enable, "updated_at": now}).Error; e != nil {
+					return e
+				}
+			}
+			return nil
+		}); err != nil {
+			return result, needRestart, err
+		}
+	}
+
+	result.Changed = len(successEmails)
+	for email, reason := range skippedReasons {
+		result.Skipped = append(result.Skipped, BulkSetEnableReport{Email: email, Reason: reason})
+	}
+	return result, needRestart, nil
+}
+
+type bulkSetEnableInboundResult struct {
+	perEmailSkipped map[string]string
+	needRestart     bool
+}
+
+func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService, inboundId int, emails []string, enable bool) bulkSetEnableInboundResult {
+	res := bulkSetEnableInboundResult{perEmailSkipped: map[string]string{}}
+
+	defer lockInbound(inboundId).Unlock()
+
+	oldInbound, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		for _, e := range emails {
+			res.perEmailSkipped[e] = err.Error()
+		}
+		return res
+	}
+
+	var settings map[string]any
+	if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
+		for _, e := range emails {
+			res.perEmailSkipped[e] = err.Error()
+		}
+		return res
+	}
+
+	wanted := make(map[string]struct{}, len(emails))
+	for _, email := range emails {
+		wanted[email] = struct{}{}
+	}
+
+	cipher := ""
+	if oldInbound.Protocol == model.Shadowsocks {
+		cipher, _ = settings["method"].(string)
+	}
+
+	type changedClient struct {
+		email     string
+		wasEnable bool
+		client    model.Client
+	}
+	var changed []changedClient
+	found := map[string]bool{}
+	nowMs := time.Now().UnixMilli()
+
+	interfaceClients, _ := settings["clients"].([]any)
+	for i, c := range interfaceClients {
+		entry, ok := c.(map[string]any)
+		if !ok {
+			continue
+		}
+		email, _ := entry["email"].(string)
+		if _, want := wanted[email]; !want || email == "" {
+			continue
+		}
+		found[email] = true
+		prev, _ := entry["enable"].(bool)
+		if prev == enable {
+			continue
+		}
+		entry["enable"] = enable
+		entry["updated_at"] = nowMs
+		interfaceClients[i] = entry
+		// Build the pushed client from the inbound JSON (the per-inbound source of
+		// truth), so a remote UpdateUser carries every field and never zeroes
+		// subId/totalGB/expiry from drifting ClientRecord columns (#4628/#4792).
+		var parsed model.Client
+		if b, mErr := json.Marshal(entry); mErr == nil {
+			_ = json.Unmarshal(b, &parsed)
+		}
+		parsed.Email = email
+		parsed.Enable = enable
+		changed = append(changed, changedClient{email: email, wasEnable: prev, client: parsed})
+	}
+
+	for email := range wanted {
+		if !found[email] {
+			res.perEmailSkipped[email] = "Client Not Found In Inbound"
+		}
+	}
+
+	if len(changed) == 0 {
+		return res
+	}
+
+	settings["clients"] = interfaceClients
+	newSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		for _, ch := range changed {
+			res.perEmailSkipped[ch.email] = err.Error()
+		}
+		return res
+	}
+	oldInbound.Settings = string(newSettings)
+
+	rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+	if perr != nil {
+		for _, ch := range changed {
+			res.perEmailSkipped[ch.email] = perr.Error()
+		}
+		return res
+	}
+	markDirty := dirty
+	if oldInbound.NodeID != nil && push && len(changed) > nodeBulkPushThreshold {
+		markDirty = true
+		push = false
+	}
+
+	txErr := runSerializedTx(func(tx *gorm.DB) error {
+		if e := tx.Save(oldInbound).Error; e != nil {
+			return e
+		}
+		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+		if gcErr != nil {
+			return gcErr
+		}
+		return s.SyncInbound(tx, inboundId, finalClients)
+	})
+	if txErr != nil {
+		for _, ch := range changed {
+			res.perEmailSkipped[ch.email] = txErr.Error()
+		}
+		return res
+	}
+
+	if oldInbound.NodeID == nil {
+		if !push {
+			res.needRestart = true
+		} else {
+			for _, ch := range changed {
+				if enable {
+					err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
+						"email":    ch.client.Email,
+						"id":       ch.client.ID,
+						"security": ch.client.Security,
+						"flow":     ch.client.Flow,
+						"auth":     ch.client.Auth,
+						"password": ch.client.Password,
+						"cipher":   cipher,
+					})
+					if err1 != nil {
+						logger.Debug("Error in adding client on", rt.Name(), ":", err1)
+						res.needRestart = true
+					}
+				} else if ch.wasEnable {
+					err1 := rt.RemoveUser(context.Background(), oldInbound, ch.email)
+					if err1 != nil && !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", ch.email)) {
+						logger.Debug("Error in removing client on", rt.Name(), ":", err1)
+						res.needRestart = true
+					}
+				}
+			}
+		}
+	} else if push {
+		for _, ch := range changed {
+			updated := ch.client
+			updated.UpdatedAt = nowMs
+			if err1 := rt.UpdateUser(context.Background(), oldInbound, ch.email, updated); err1 != nil {
+				logger.Warning("Error in updating client on", rt.Name(), ":", err1)
+				markDirty = true
+			}
+		}
+	}
+
+	if markDirty && oldInbound.NodeID != nil {
+		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+			logger.Warning("mark node dirty failed:", dErr)
+		}
+	}
+
+	return res
+}

+ 10 - 0
internal/web/translation/ar-EG.json

@@ -816,6 +816,12 @@
       "attach": "إرفاق",
       "adjust": "ضبط",
       "subLinks": "روابط الاشتراك",
+      "enable": "تفعيل",
+      "disable": "تعطيل",
+      "bulkEnableConfirmTitle": "تفعيل {count} عميل؟",
+      "bulkEnableConfirmContent": "يُفعّل كل عميل محدد على جميع الإدخالات المرفقة. العملاء الذين استُنفدت حصتهم أو انتهت صلاحيتهم سيُعطَّلون تلقائيًا مرة أخرى.",
+      "bulkDisableConfirmTitle": "تعطيل {count} عميل؟",
+      "bulkDisableConfirmContent": "يُعطّل كل عميل محدد على جميع الإدخالات المرفقة. يفقدون الوصول فورًا لكن تُحفَظ سجلاتهم وحركة بياناتهم.",
       "selectedCount": "{count} محدد",
       "attachSelected": "إرفاق ({count})",
       "attachToInboundsTitle": "إرفاق {count} عميل بالواردات",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "تمت إعادة ضبط حركة مرور كل العملاء",
         "bulkDeleted": "تم حذف {count} عميل",
         "bulkDeletedMixed": "تم حذف {ok}, وفشل {failed}",
+        "bulkEnabled": "تم تفعيل {count} عميل",
+        "bulkEnabledMixed": "تم تفعيل {ok}, وفشل {failed}",
+        "bulkDisabled": "تم تعطيل {count} عميل",
+        "bulkDisabledMixed": "تم تعطيل {ok}, وفشل {failed}",
         "bulkCreated": "تم إنشاء {count} عميل",
         "bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}",
         "bulkAdjusted": "تم تعديل {count} عميل",

+ 10 - 0
internal/web/translation/en-US.json

@@ -816,6 +816,12 @@
       "attach": "Attach",
       "adjust": "Adjust",
       "subLinks": "Sub links",
+      "enable": "Enable",
+      "disable": "Disable",
+      "bulkEnableConfirmTitle": "Enable {count} clients?",
+      "bulkEnableConfirmContent": "Enables each selected client on every attached inbound. Clients whose quota is exhausted or whose expiry has passed will be disabled again automatically.",
+      "bulkDisableConfirmTitle": "Disable {count} clients?",
+      "bulkDisableConfirmContent": "Disables each selected client on every attached inbound. They lose access immediately but their records and traffic are kept.",
       "selectedCount": "{count} selected",
       "attachSelected": "Attach ({count})",
       "attachToInboundsTitle": "Attach {count} client(s) to inbound(s)",
@@ -875,6 +881,10 @@
         "allTrafficsReset": "All client traffic reset",
         "bulkDeleted": "{count} clients deleted",
         "bulkDeletedMixed": "{ok} deleted, {failed} failed",
+        "bulkEnabled": "{count} clients enabled",
+        "bulkEnabledMixed": "{ok} enabled, {failed} failed",
+        "bulkDisabled": "{count} clients disabled",
+        "bulkDisabledMixed": "{ok} disabled, {failed} failed",
         "bulkCreated": "{count} clients created",
         "bulkCreatedMixed": "{ok} created, {failed} failed",
         "bulkAdjusted": "{count} clients adjusted",

+ 10 - 0
internal/web/translation/es-ES.json

@@ -816,6 +816,12 @@
       "attach": "Asociar",
       "adjust": "Ajustar",
       "subLinks": "Enlaces sub",
+      "enable": "Habilitar",
+      "disable": "Deshabilitar",
+      "bulkEnableConfirmTitle": "¿Habilitar {count} clientes?",
+      "bulkEnableConfirmContent": "Habilita cada cliente seleccionado en todos los inbounds asociados. Los clientes cuya cuota se haya agotado o cuya caducidad haya pasado se deshabilitarán de nuevo automáticamente.",
+      "bulkDisableConfirmTitle": "¿Deshabilitar {count} clientes?",
+      "bulkDisableConfirmContent": "Deshabilita cada cliente seleccionado en todos los inbounds asociados. Pierden el acceso de inmediato, pero se conservan sus registros y su tráfico.",
       "selectedCount": "{count} seleccionado(s)",
       "attachSelected": "Asociar ({count})",
       "attachToInboundsTitle": "Asociar {count} cliente(s) a entrada(s)",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "Tráfico de todos los clientes restablecido",
         "bulkDeleted": "{count} clientes eliminados",
         "bulkDeletedMixed": "{ok} eliminados, {failed} fallidos",
+        "bulkEnabled": "{count} clientes habilitados",
+        "bulkEnabledMixed": "{ok} habilitados, {failed} fallidos",
+        "bulkDisabled": "{count} clientes deshabilitados",
+        "bulkDisabledMixed": "{ok} deshabilitados, {failed} fallidos",
         "bulkCreated": "{count} clientes creados",
         "bulkCreatedMixed": "{ok} creados, {failed} fallidos",
         "bulkAdjusted": "{count} clientes ajustados",

+ 10 - 0
internal/web/translation/fa-IR.json

@@ -816,6 +816,12 @@
       "attach": "الصاق",
       "adjust": "تنظیم",
       "subLinks": "لینک‌های اشتراک",
+      "enable": "فعال‌سازی",
+      "disable": "غیرفعال‌سازی",
+      "bulkEnableConfirmTitle": "{count} کلاینت فعال شوند؟",
+      "bulkEnableConfirmContent": "هر کلاینت انتخاب‌شده روی تمام اینباندهای متصل فعال می‌شود. کلاینت‌هایی که سهمیه آن‌ها تمام شده یا تاریخ انقضایشان گذشته، به‌طور خودکار دوباره غیرفعال می‌شوند.",
+      "bulkDisableConfirmTitle": "{count} کلاینت غیرفعال شوند؟",
+      "bulkDisableConfirmContent": "هر کلاینت انتخاب‌شده روی تمام اینباندهای متصل غیرفعال می‌شود. دسترسی آن‌ها بلافاصله قطع می‌شود اما رکورد و ترافیکشان حفظ می‌گردد.",
       "selectedCount": "{count} انتخاب‌شده",
       "attachSelected": "الصاق ({count})",
       "attachToInboundsTitle": "الصاق {count} کاربر به ورودی‌(ها)",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "ترافیک همه کلاینت‌ها بازنشانی شد",
         "bulkDeleted": "{count} کلاینت حذف شد",
         "bulkDeletedMixed": "{ok} حذف، {failed} ناموفق",
+        "bulkEnabled": "{count} کلاینت فعال شد",
+        "bulkEnabledMixed": "{ok} فعال، {failed} ناموفق",
+        "bulkDisabled": "{count} کلاینت غیرفعال شد",
+        "bulkDisabledMixed": "{ok} غیرفعال، {failed} ناموفق",
         "bulkCreated": "{count} کلاینت ساخته شد",
         "bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق",
         "bulkAdjusted": "{count} کلاینت تنظیم شد",

+ 10 - 0
internal/web/translation/id-ID.json

@@ -816,6 +816,12 @@
       "attach": "Lampirkan",
       "adjust": "Atur",
       "subLinks": "Tautan sub",
+      "enable": "Aktifkan",
+      "disable": "Nonaktifkan",
+      "bulkEnableConfirmTitle": "Aktifkan {count} klien?",
+      "bulkEnableConfirmContent": "Mengaktifkan setiap klien yang dipilih di semua inbound yang terlampir. Klien yang kuotanya habis atau masa berlakunya telah lewat akan dinonaktifkan kembali secara otomatis.",
+      "bulkDisableConfirmTitle": "Nonaktifkan {count} klien?",
+      "bulkDisableConfirmContent": "Menonaktifkan setiap klien yang dipilih di semua inbound yang terlampir. Mereka langsung kehilangan akses, tetapi catatan dan trafiknya tetap disimpan.",
       "selectedCount": "{count} dipilih",
       "attachSelected": "Lampirkan ({count})",
       "attachToInboundsTitle": "Lampirkan {count} klien ke inbound",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "Lalu lintas semua klien direset",
         "bulkDeleted": "{count} klien dihapus",
         "bulkDeletedMixed": "{ok} dihapus, {failed} gagal",
+        "bulkEnabled": "{count} klien diaktifkan",
+        "bulkEnabledMixed": "{ok} diaktifkan, {failed} gagal",
+        "bulkDisabled": "{count} klien dinonaktifkan",
+        "bulkDisabledMixed": "{ok} dinonaktifkan, {failed} gagal",
         "bulkCreated": "{count} klien dibuat",
         "bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
         "bulkAdjusted": "{count} klien disesuaikan",

+ 10 - 0
internal/web/translation/ja-JP.json

@@ -816,6 +816,12 @@
       "attach": "アタッチ",
       "adjust": "調整",
       "subLinks": "サブリンク",
+      "enable": "有効化",
+      "disable": "無効化",
+      "bulkEnableConfirmTitle": "{count} 件のクライアントを有効化しますか?",
+      "bulkEnableConfirmContent": "選択した各クライアントを、接続されているすべてのインバウンドで有効化します。クォータを使い切ったクライアントや有効期限が過ぎたクライアントは、自動的に再度無効化されます。",
+      "bulkDisableConfirmTitle": "{count} 件のクライアントを無効化しますか?",
+      "bulkDisableConfirmContent": "選択した各クライアントを、接続されているすべてのインバウンドで無効化します。アクセスはすぐに失われますが、記録とトラフィックは保持されます。",
       "selectedCount": "{count} 選択中",
       "attachSelected": "アタッチ ({count})",
       "attachToInboundsTitle": "{count} クライアントをインバウンドにアタッチ",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "すべてのクライアントのトラフィックをリセットしました",
         "bulkDeleted": "{count} 件のクライアントを削除しました",
         "bulkDeletedMixed": "{ok} 件削除、{failed} 件失敗",
+        "bulkEnabled": "{count} 件のクライアントを有効化しました",
+        "bulkEnabledMixed": "{ok} 件有効化、{failed} 件失敗",
+        "bulkDisabled": "{count} 件のクライアントを無効化しました",
+        "bulkDisabledMixed": "{ok} 件無効化、{failed} 件失敗",
         "bulkCreated": "{count} 件のクライアントを作成しました",
         "bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗",
         "bulkAdjusted": "{count} 件のクライアントを調整しました",

+ 10 - 0
internal/web/translation/pt-BR.json

@@ -816,6 +816,12 @@
       "attach": "Associar",
       "adjust": "Ajustar",
       "subLinks": "Links de assinatura",
+      "enable": "Ativar",
+      "disable": "Desativar",
+      "bulkEnableConfirmTitle": "Ativar {count} clientes?",
+      "bulkEnableConfirmContent": "Ativa cada cliente selecionado em todos os inbounds associados. Clientes cuja cota se esgotou ou cuja validade expirou serão desativados novamente de forma automática.",
+      "bulkDisableConfirmTitle": "Desativar {count} clientes?",
+      "bulkDisableConfirmContent": "Desativa cada cliente selecionado em todos os inbounds associados. Eles perdem o acesso imediatamente, mas seus registros e tráfego são mantidos.",
       "selectedCount": "{count} selecionado(s)",
       "attachSelected": "Associar ({count})",
       "attachToInboundsTitle": "Associar {count} cliente(s) a entrada(s)",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "Tráfego de todos os clientes redefinido",
         "bulkDeleted": "{count} clientes excluídos",
         "bulkDeletedMixed": "{ok} excluídos, {failed} com falha",
+        "bulkEnabled": "{count} clientes ativados",
+        "bulkEnabledMixed": "{ok} ativados, {failed} com falha",
+        "bulkDisabled": "{count} clientes desativados",
+        "bulkDisabledMixed": "{ok} desativados, {failed} com falha",
         "bulkCreated": "{count} clientes criados",
         "bulkCreatedMixed": "{ok} criados, {failed} com falha",
         "bulkAdjusted": "{count} clientes ajustados",

+ 10 - 0
internal/web/translation/ru-RU.json

@@ -816,6 +816,12 @@
       "attach": "Привязать",
       "adjust": "Корректировка",
       "subLinks": "Sub-ссылки",
+      "enable": "Включить",
+      "disable": "Отключить",
+      "bulkEnableConfirmTitle": "Включить {count} клиентов?",
+      "bulkEnableConfirmContent": "Включает каждого выбранного клиента на всех привязанных подключениях. Клиенты с исчерпанной квотой или истёкшим сроком будут автоматически отключены снова.",
+      "bulkDisableConfirmTitle": "Отключить {count} клиентов?",
+      "bulkDisableConfirmContent": "Отключает каждого выбранного клиента на всех привязанных подключениях. Они сразу теряют доступ, но их записи и трафик сохраняются.",
       "selectedCount": "{count} выбрано",
       "attachSelected": "Привязать ({count})",
       "attachToInboundsTitle": "Привязать {count} клиент(ов) к входящим",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "Трафик всех клиентов сброшен",
         "bulkDeleted": "Удалено клиентов: {count}",
         "bulkDeletedMixed": "Удалено: {ok}, не удалось: {failed}",
+        "bulkEnabled": "Включено клиентов: {count}",
+        "bulkEnabledMixed": "Включено: {ok}, не удалось: {failed}",
+        "bulkDisabled": "Отключено клиентов: {count}",
+        "bulkDisabledMixed": "Отключено: {ok}, не удалось: {failed}",
         "bulkCreated": "Создано клиентов: {count}",
         "bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}",
         "bulkAdjusted": "Изменено клиентов: {count}",

+ 10 - 0
internal/web/translation/tr-TR.json

@@ -816,6 +816,12 @@
       "attach": "Bağla",
       "adjust": "Ayarla",
       "subLinks": "Abonelik Bağlantıları",
+      "enable": "Etkinleştir",
+      "disable": "Devre Dışı Bırak",
+      "bulkEnableConfirmTitle": "{count} kullanıcı etkinleştirilsin mi?",
+      "bulkEnableConfirmContent": "Seçili her kullanıcıyı bağlı olduğu tüm gelen bağlantılarda etkinleştirir. Kotası dolmuş veya süresi geçmiş kullanıcılar otomatik olarak yeniden devre dışı bırakılır.",
+      "bulkDisableConfirmTitle": "{count} kullanıcı devre dışı bırakılsın mı?",
+      "bulkDisableConfirmContent": "Seçili her kullanıcıyı bağlı olduğu tüm gelen bağlantılarda devre dışı bırakır. Erişimlerini hemen kaybederler ancak kayıtları ve trafikleri korunur.",
       "selectedCount": "{count} Seçildi",
       "attachSelected": "Bağla ({count})",
       "attachToInboundsTitle": "{count} Kullanıcıyı Gelen Bağlantına Bağla",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "Tüm kullanıcıların trafiği sıfırlandı",
         "bulkDeleted": "{count} kullanıcı silindi",
         "bulkDeletedMixed": "{ok} silindi, {failed} başarısız",
+        "bulkEnabled": "{count} kullanıcı etkinleştirildi",
+        "bulkEnabledMixed": "{ok} etkinleştirildi, {failed} başarısız",
+        "bulkDisabled": "{count} kullanıcı devre dışı bırakıldı",
+        "bulkDisabledMixed": "{ok} devre dışı bırakıldı, {failed} başarısız",
         "bulkCreated": "{count} kullanıcı oluşturuldu",
         "bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
         "bulkAdjusted": "{count} kullanıcı ayarlandı",

+ 10 - 0
internal/web/translation/uk-UA.json

@@ -816,6 +816,12 @@
       "attach": "Прив'язати",
       "adjust": "Коригування",
       "subLinks": "Sub-посилання",
+      "enable": "Увімкнути",
+      "disable": "Вимкнути",
+      "bulkEnableConfirmTitle": "Увімкнути {count} клієнтів?",
+      "bulkEnableConfirmContent": "Вмикає кожного вибраного клієнта на всіх прив'язаних підключеннях. Клієнти з вичерпаною квотою або простроченим терміном будуть автоматично вимкнені знову.",
+      "bulkDisableConfirmTitle": "Вимкнути {count} клієнтів?",
+      "bulkDisableConfirmContent": "Вимикає кожного вибраного клієнта на всіх прив'язаних підключеннях. Вони одразу втрачають доступ, але їхні записи та трафік зберігаються.",
       "selectedCount": "Обрано {count}",
       "attachSelected": "Прив'язати ({count})",
       "attachToInboundsTitle": "Прив'язати {count} клієнт(ів) до вхідних",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "Трафік усіх клієнтів скинуто",
         "bulkDeleted": "Видалено клієнтів: {count}",
         "bulkDeletedMixed": "Видалено: {ok}, не вдалось: {failed}",
+        "bulkEnabled": "Увімкнено клієнтів: {count}",
+        "bulkEnabledMixed": "Увімкнено: {ok}, не вдалось: {failed}",
+        "bulkDisabled": "Вимкнено клієнтів: {count}",
+        "bulkDisabledMixed": "Вимкнено: {ok}, не вдалось: {failed}",
         "bulkCreated": "Створено клієнтів: {count}",
         "bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}",
         "bulkAdjusted": "Змінено клієнтів: {count}",

+ 10 - 0
internal/web/translation/vi-VN.json

@@ -816,6 +816,12 @@
       "attach": "Gắn",
       "adjust": "Điều chỉnh",
       "subLinks": "Liên kết sub",
+      "enable": "Bật",
+      "disable": "Tắt",
+      "bulkEnableConfirmTitle": "Bật {count} khách hàng?",
+      "bulkEnableConfirmContent": "Bật từng khách hàng đã chọn trên mọi inbound được gắn. Những khách hàng đã dùng hết hạn mức hoặc đã hết hạn sẽ tự động bị tắt lại.",
+      "bulkDisableConfirmTitle": "Tắt {count} khách hàng?",
+      "bulkDisableConfirmContent": "Tắt từng khách hàng đã chọn trên mọi inbound được gắn. Họ mất quyền truy cập ngay lập tức nhưng hồ sơ và lưu lượng của họ vẫn được giữ lại.",
       "selectedCount": "Đã chọn {count}",
       "attachSelected": "Gắn ({count})",
       "attachToInboundsTitle": "Gắn {count} client vào inbound",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "Đã đặt lại lưu lượng của tất cả khách hàng",
         "bulkDeleted": "Đã xóa {count} khách hàng",
         "bulkDeletedMixed": "Đã xóa {ok}, thất bại {failed}",
+        "bulkEnabled": "Đã bật {count} khách hàng",
+        "bulkEnabledMixed": "Đã bật {ok}, thất bại {failed}",
+        "bulkDisabled": "Đã tắt {count} khách hàng",
+        "bulkDisabledMixed": "Đã tắt {ok}, thất bại {failed}",
         "bulkCreated": "Đã tạo {count} khách hàng",
         "bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}",
         "bulkAdjusted": "Đã điều chỉnh {count} khách hàng",

+ 10 - 0
internal/web/translation/zh-CN.json

@@ -816,6 +816,12 @@
       "attach": "附加",
       "adjust": "调整",
       "subLinks": "订阅链接",
+      "enable": "启用",
+      "disable": "禁用",
+      "bulkEnableConfirmTitle": "启用 {count} 个客户端?",
+      "bulkEnableConfirmContent": "在每个已附加的入站上启用所选的客户端。配额已用尽或已过期的客户端将被自动重新禁用。",
+      "bulkDisableConfirmTitle": "禁用 {count} 个客户端?",
+      "bulkDisableConfirmContent": "在每个已附加的入站上禁用所选的客户端。他们将立即失去访问权限,但其记录和流量将被保留。",
       "selectedCount": "已选 {count} 项",
       "attachSelected": "附加 ({count})",
       "attachToInboundsTitle": "将 {count} 个客户端附加到入站",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "所有客户端流量已重置",
         "bulkDeleted": "已删除 {count} 个客户端",
         "bulkDeletedMixed": "已删除 {ok} 个,失败 {failed} 个",
+        "bulkEnabled": "已启用 {count} 个客户端",
+        "bulkEnabledMixed": "已启用 {ok} 个,失败 {failed} 个",
+        "bulkDisabled": "已禁用 {count} 个客户端",
+        "bulkDisabledMixed": "已禁用 {ok} 个,失败 {failed} 个",
         "bulkCreated": "已创建 {count} 个客户端",
         "bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个",
         "bulkAdjusted": "已调整 {count} 个客户端",

+ 10 - 0
internal/web/translation/zh-TW.json

@@ -816,6 +816,12 @@
       "attach": "附加",
       "adjust": "調整",
       "subLinks": "訂閱連結",
+      "enable": "啟用",
+      "disable": "停用",
+      "bulkEnableConfirmTitle": "啟用 {count} 個客戶端?",
+      "bulkEnableConfirmContent": "在每個已附加的入站上啟用所選的客戶端。配額已用盡或已過期的客戶端將被自動重新停用。",
+      "bulkDisableConfirmTitle": "停用 {count} 個客戶端?",
+      "bulkDisableConfirmContent": "在每個已附加的入站上停用所選的客戶端。他們將立即失去存取權限,但其記錄與流量將被保留。",
       "selectedCount": "已選 {count} 項",
       "attachSelected": "附加 ({count})",
       "attachToInboundsTitle": "將 {count} 個客戶端附加到入站",
@@ -872,6 +878,10 @@
         "allTrafficsReset": "所有客戶端流量已重設",
         "bulkDeleted": "已刪除 {count} 個客戶端",
         "bulkDeletedMixed": "已刪除 {ok} 個,失敗 {failed} 個",
+        "bulkEnabled": "已啟用 {count} 個客戶端",
+        "bulkEnabledMixed": "已啟用 {ok} 個,失敗 {failed} 個",
+        "bulkDisabled": "已停用 {count} 個客戶端",
+        "bulkDisabledMixed": "已停用 {ok} 個,失敗 {failed} 個",
         "bulkCreated": "已建立 {count} 個客戶端",
         "bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個",
         "bulkAdjusted": "已調整 {count} 個客戶端",