mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 12:54:33 +00:00
feat(clients): selective bulk attach + new bulk detach
Inbounds page: - AttachClientsModal now shows a per-client selection table (email, comment, enabled tag) with search and a live "selected of total" counter; all clients are pre-selected so the old "attach all" workflow stays a single OK click. - New DetachClientsModal on the inbound row menu lets you pick which clients to remove from that inbound (records are kept so they can be re-attached later; for full removal use Delete). Clients page: - New "Attach (N)" bulk-action button + BulkAttachInboundsModal that attaches selected clients to one or more multi-user inbounds. - New "Detach (N)" bulk-action button + BulkDetachInboundsModal that removes selected clients from chosen inbounds; (email, inbound) pairs where the client isn't attached are silently skipped. Backend adds POST /panel/api/clients/bulkDetach, wrapping the existing Detach service for each email and reporting per-email detached/skipped/errors. ClientRecord rows are kept on detach to match the single-client endpoint; bulkDel remains the path for full removal.
This commit is contained in:
@@ -49,6 +49,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/bulkCreate", a.bulkCreate)
|
||||
g.POST("/bulkAssignGroup", a.bulkAssignGroup)
|
||||
g.POST("/bulkAttach", a.bulkAttach)
|
||||
g.POST("/bulkDetach", a.bulkDetach)
|
||||
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
|
||||
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
|
||||
g.POST("/ips/:email", a.getIps)
|
||||
@@ -263,6 +264,29 @@ func (a *ClientController) bulkAttach(c *gin.Context) {
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkDetachRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
func (a *ClientController) bulkDetach(c *gin.Context) {
|
||||
var req bulkDetachRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
result, needRestart, err := a.clientService.BulkDetach(&a.inboundService, req.Emails, req.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) bulkDelete(c *gin.Context) {
|
||||
var req bulkDeleteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -884,6 +884,75 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
// BulkDetachResult reports the outcome of a bulk detach across target inbounds.
|
||||
type BulkDetachResult struct {
|
||||
Detached []string `json:"detached"`
|
||||
Skipped []string `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// BulkDetach detaches the given existing clients (by email) from each target inbound.
|
||||
// (email, inbound) pairs where the client is not currently attached are silently skipped
|
||||
// at the inbound level; emails that aren't attached to any of the requested inbounds
|
||||
// are reported under skipped. ClientRecord rows are kept even when they become orphaned
|
||||
// (matches single-client detach semantics); callers should use bulkDelete for full removal.
|
||||
func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string, inboundIds []int) (*BulkDetachResult, bool, error) {
|
||||
result := &BulkDetachResult{}
|
||||
if len(emails) == 0 || len(inboundIds) == 0 {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
requested := make(map[int]struct{}, len(inboundIds))
|
||||
for _, id := range inboundIds {
|
||||
requested[id] = struct{}{}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
seenEmail := make(map[string]struct{}, len(emails))
|
||||
for _, email := range emails {
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(email)
|
||||
if _, ok := seenEmail[key]; ok {
|
||||
continue
|
||||
}
|
||||
seenEmail[key] = struct{}{}
|
||||
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err))
|
||||
continue
|
||||
}
|
||||
currentIds, err := s.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err))
|
||||
continue
|
||||
}
|
||||
intersection := make([]int, 0, len(currentIds))
|
||||
for _, id := range currentIds {
|
||||
if _, ok := requested[id]; ok {
|
||||
intersection = append(intersection, id)
|
||||
}
|
||||
}
|
||||
if len(intersection) == 0 {
|
||||
result.Skipped = append(result.Skipped, rec.Email)
|
||||
continue
|
||||
}
|
||||
nr, err := s.Detach(inboundSvc, rec.Id, intersection)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", rec.Email, err))
|
||||
continue
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
result.Detached = append(result.Detached, rec.Email)
|
||||
}
|
||||
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
|
||||
@@ -306,6 +306,16 @@
|
||||
"attachClientsNoTargets": "No other compatible inbounds available to attach to.",
|
||||
"attachClientsResult": "Attached {attached}, skipped {skipped}.",
|
||||
"attachClientsResultMixed": "Attached {attached}, skipped {skipped}, errors {errors}.",
|
||||
"attachClientsSelectLabel": "Clients to attach",
|
||||
"attachClientsSearchPlaceholder": "Search email or comment",
|
||||
"attachClientsStatusDisabled": "Disabled",
|
||||
"attachClientsSelectedCount": "{selected} of {total} selected",
|
||||
"detachClients": "Detach Clients",
|
||||
"detachClientsTitle": "Detach clients of \"{remark}\"",
|
||||
"detachClientsDesc": "Removes the selected client(s) from this inbound only. Client records themselves are kept (use Delete to remove fully). Source has {count} clients in total.",
|
||||
"detachClientsResult": "Detached {detached}, skipped {skipped}.",
|
||||
"detachClientsResultMixed": "Detached {detached}, skipped {skipped}, errors {errors}.",
|
||||
"detachClientsSelectLabel": "Clients to detach",
|
||||
"exportLinksTitle": "Export inbound links",
|
||||
"exportSubsTitle": "Export subscription links",
|
||||
"exportAllLinksTitle": "Export all inbound links",
|
||||
@@ -532,6 +542,19 @@
|
||||
"assignGroupPlaceholder": "Group name (leave blank to clear)",
|
||||
"assignGroupAssignedToast": "Assigned {count} client(s) to {group}",
|
||||
"assignGroupClearedToast": "Cleared group from {count} client(s)",
|
||||
"attachSelected": "Attach ({count})",
|
||||
"attachToInboundsTitle": "Attach {count} client(s) to inbound(s)",
|
||||
"attachToInboundsDesc": "Attaches the selected {count} client(s) (same UUID/password and shared traffic) to the chosen inbound(s). They keep their existing attachments too.",
|
||||
"attachToInboundsTargets": "Target inbounds",
|
||||
"attachToInboundsNoTargets": "No multi-user inbounds available to attach to.",
|
||||
"detachSelected": "Detach ({count})",
|
||||
"detach": "Detach",
|
||||
"detachFromInboundsTitle": "Detach {count} client(s) from inbound(s)",
|
||||
"detachFromInboundsDesc": "Removes the selected {count} client(s) from the chosen inbound(s). Pairs where the client wasn't attached are silently skipped. Client records are kept (use Delete to remove fully).",
|
||||
"detachFromInboundsTargets": "Inbounds to detach from",
|
||||
"detachFromInboundsNoTargets": "No multi-user inbounds available.",
|
||||
"detachFromInboundsResult": "Detached {detached}, skipped {skipped}.",
|
||||
"detachFromInboundsResultMixed": "Detached {detached}, skipped {skipped}, errors {errors}.",
|
||||
"subLinksTitle": "Sub links ({count})",
|
||||
"subLinkColumn": "Subscription URL",
|
||||
"subJsonLinkColumn": "Subscription JSON URL",
|
||||
|
||||
@@ -301,6 +301,16 @@
|
||||
"attachClientsNoTargets": "اینباند سازگار دیگری برای اتصال وجود ندارد.",
|
||||
"attachClientsResult": "{attached} متصل شد، {skipped} رد شد.",
|
||||
"attachClientsResultMixed": "{attached} متصل شد، {skipped} رد شد، {errors} خطا.",
|
||||
"attachClientsSelectLabel": "کلاینتهای قابل اتصال",
|
||||
"attachClientsSearchPlaceholder": "جستجوی ایمیل یا توضیح",
|
||||
"attachClientsStatusDisabled": "غیرفعال",
|
||||
"attachClientsSelectedCount": "{selected} از {total} انتخابشده",
|
||||
"detachClients": "جداسازی کلاینتها",
|
||||
"detachClientsTitle": "جداسازی کلاینتهای «{remark}»",
|
||||
"detachClientsDesc": "کلاینتهای انتخابشده فقط از همین اینباند جدا میشوند. خود رکورد کلاینتها حفظ میشود (برای حذف کامل از Delete استفاده کنید). این اینباند مجموعاً {count} کلاینت دارد.",
|
||||
"detachClientsResult": "{detached} جدا شد، {skipped} رد شد.",
|
||||
"detachClientsResultMixed": "{detached} جدا شد، {skipped} رد شد، {errors} خطا.",
|
||||
"detachClientsSelectLabel": "کلاینتهای قابل جداسازی",
|
||||
"exportLinksTitle": "خروجی لینکهای اینباند",
|
||||
"exportSubsTitle": "خروجی لینکهای ساب",
|
||||
"exportAllLinksTitle": "خروجی لینکهای همه اینباندها",
|
||||
@@ -503,6 +513,19 @@
|
||||
"deleteConfirmContent": "این کلاینت از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک میشود. این عمل غیرقابل بازگشت است.",
|
||||
"deleteSelected": "حذف ({count})",
|
||||
"adjustSelected": "تنظیم ({count})",
|
||||
"attachSelected": "اتصال ({count})",
|
||||
"attachToInboundsTitle": "اتصال {count} کلاینت به اینباند(ها)",
|
||||
"attachToInboundsDesc": "{count} کلاینت انتخابشده (با همان UUID/پسورد و ترافیک مشترک) به اینباند(های) انتخابی متصل میشوند. روی اینباندهای فعلی هم باقی میمانند.",
|
||||
"attachToInboundsTargets": "اینباندهای مقصد",
|
||||
"attachToInboundsNoTargets": "اینباند سازگار برای اتصال وجود ندارد.",
|
||||
"detachSelected": "جداسازی ({count})",
|
||||
"detach": "جداسازی",
|
||||
"detachFromInboundsTitle": "جداسازی {count} کلاینت از اینباند(ها)",
|
||||
"detachFromInboundsDesc": "{count} کلاینت انتخابشده از اینباند(های) انتخابی جدا میشوند. زوجهایی که کلاینت در آنها متصل نیست بیصدا رد میشوند. خود رکورد کلاینتها حفظ میشود (برای حذف کامل از Delete استفاده کنید).",
|
||||
"detachFromInboundsTargets": "اینباندهای مبدأ",
|
||||
"detachFromInboundsNoTargets": "اینباند سازگار وجود ندارد.",
|
||||
"detachFromInboundsResult": "{detached} جدا شد، {skipped} رد شد.",
|
||||
"detachFromInboundsResultMixed": "{detached} جدا شد، {skipped} رد شد، {errors} خطا.",
|
||||
"bulkDeleteConfirmTitle": "حذف {count} کلاینت؟",
|
||||
"bulkDeleteConfirmContent": "هر کلاینت انتخابشده از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک میشود. این عمل غیرقابل بازگشت است.",
|
||||
"bulkAdjustTitle": "تنظیم {count} کلاینت",
|
||||
|
||||
Reference in New Issue
Block a user