diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 0cf8edb68..2310f602c 100644 --- a/frontend/public/openapi.json +++ b/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": [ diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index a1780e3f6..21575a847 100644 --- a/frontend/src/hooks/useClients.ts +++ b/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> => { + 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); + 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); + 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, diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index dd1a88341..479d2e347 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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', diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index d4882c63f..bbd1333c9 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/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')} ) : ( - <> - setSelectedRowKeys([])} - style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }} - > - {t('pages.clients.selectedCount', { count: selectedRowKeys.length })} - - - - - - + setSelectedRowKeys([])} + style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }} + > + {t('pages.clients.selectedCount', { count: selectedRowKeys.length })} + )} 0 ? [ + { + key: 'attach', + icon: , + label: t('pages.clients.attach'), + onClick: () => setBulkAttachOpen(true), + }, + { + key: 'detach', + icon: , + label: t('pages.clients.detach'), + danger: true, + onClick: () => setBulkDetachOpen(true), + }, + { + key: 'addToGroup', + icon: , + label: t('pages.clients.addToGroup'), + onClick: () => setBulkGroupOpen(true), + }, + { + key: 'ungroup', + icon: , + label: t('pages.clients.ungroup'), + danger: true, + onClick: onBulkUngroup, + }, + { type: 'divider' as const }, + { + key: 'enable', + icon: , + label: t('pages.clients.enable'), + onClick: () => onBulkSetEnable(true), + }, + { + key: 'disable', + icon: , + label: t('pages.clients.disable'), + danger: true, + onClick: () => onBulkSetEnable(false), + }, { key: 'adjust', icon: , diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 4aec02a77..732dfd585 100644 --- a/frontend/src/schemas/client.ts +++ b/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; export type ClientHydrate = z.infer; export type BulkAdjustResult = z.infer; export type BulkDeleteResult = z.infer; +export type BulkSetEnableResult = z.infer; export type BulkCreateResult = z.infer; export type BulkAttachResult = z.infer; export type BulkDetachResult = z.infer; diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index ae9f857b9..093217ea8 100644 --- a/internal/web/controller/client.go +++ b/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 { diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index bbd5985d9..f212fe798 100644 --- a/internal/web/service/client_bulk.go +++ b/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 +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 427c03326..82bad212f 100644 --- a/internal/web/translation/ar-EG.json +++ b/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} عميل", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 8e75954a5..9084cddb3 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 7bcf38e19..8653ae1bf 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 8de92bde4..68f0bf42e 100644 --- a/internal/web/translation/fa-IR.json +++ b/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} کلاینت تنظیم شد", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index a30f5576a..f8d17c16f 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index b525adeba..0b717a7f2 100644 --- a/internal/web/translation/ja-JP.json +++ b/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} 件のクライアントを調整しました", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index edfee7c52..9d1dbb152 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 5135381af..ab934f9dd 100644 --- a/internal/web/translation/ru-RU.json +++ b/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}", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 84054f775..cfe431fab 100644 --- a/internal/web/translation/tr-TR.json +++ b/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ı", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index a84354bfe..e0edee8dd 100644 --- a/internal/web/translation/uk-UA.json +++ b/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}", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 512f2e478..2cd230274 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6b589cdc6..e61323228 100644 --- a/internal/web/translation/zh-CN.json +++ b/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} 个客户端", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 82daada7f..eefa966d2 100644 --- a/internal/web/translation/zh-TW.json +++ b/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} 個客戶端",