feat(clients): orphan cleanup + export/import via CodeMirror modals

Add three client-management actions to the Clients page More menu:

- Delete unattached clients: removes every client with no inbound
  attachment, cascading its traffic rows, IP log, and external links
  (POST /clients/delOrphans).
- Export clients: shows the {client, inboundIds} list in a read-only
  CodeMirror viewer with copy/download (GET /clients/export returns the
  array in the standard envelope).
- Import clients: pastes that JSON into an editable CodeMirror editor,
  mirroring Import an Inbound (POST /clients/import takes a { data }
  body). Attached clients go through the create-and-attach path; items
  with no inboundIds are restored as bare records; existing emails are
  never overwritten and are reported as skipped.

Document the new endpoints in api-docs and translate the new strings
into all supported languages.
This commit is contained in:
MHSanaei
2026-06-21 23:06:10 +02:00
parent 0483273839
commit 0b0b6250d6
19 changed files with 736 additions and 14 deletions
+56
View File
@@ -1,6 +1,7 @@
package controller
import (
"encoding/json"
"strconv"
"strings"
@@ -57,6 +58,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/:email/attach", a.attach)
g.POST("/:email/detach", a.detach)
g.POST("/:email/externalLinks", a.setExternalLinks)
g.GET("/export", a.export)
g.POST("/import", a.importClients)
g.POST("/delOrphans", a.delOrphans)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/delDepleted", a.delDepleted)
g.POST("/bulkAdjust", a.bulkAdjust)
@@ -364,6 +368,58 @@ func (a *ClientController) delDepleted(c *gin.Context) {
notifyClientsChanged()
}
// export returns every client as a {client, inboundIds} list in the standard
// envelope. The frontend renders it in a read-only CodeMirror viewer (Copy /
// Download), so this hands back data rather than streaming a file attachment.
func (a *ClientController) export(c *gin.Context) {
items, err := a.clientService.ExportAll()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, items, nil)
}
type importClientsRequest struct {
Data string `json:"data"`
}
// importClients accepts the pasted export text as a JSON body { "data": "..." },
// mirroring the inbound import flow. The data string is itself a JSON-encoded
// []ClientCreatePayload, so it is unmarshalled in a second step.
func (a *ClientController) importClients(c *gin.Context) {
var req importClientsRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
var items []service.ClientCreatePayload
if err := json.Unmarshal([]byte(req.Data), &items); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.ImportClients(&a.inboundService, items)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) delOrphans(c *gin.Context) {
deleted, err := a.clientService.DeleteOrphans()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"deleted": deleted}, nil)
notifyClientsChanged()
}
func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
email := c.Param("email")
needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
+220
View File
@@ -0,0 +1,220 @@
package service
import (
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// ExportAll returns every client in the same {client, inboundIds} shape that
// /add and /bulkCreate accept, so an exported file round-trips straight back
// through Import. Clients with no inbound attachment are included with an empty
// inboundIds list so an export taken before DeleteOrphans can restore them.
func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
db := database.GetDB()
var rows []model.ClientRecord
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]ClientCreatePayload, 0, len(rows))
if len(rows) == 0 {
return out, nil
}
ids := make([]int, 0, len(rows))
for i := range rows {
ids = append(ids, rows[i].Id)
}
attachments := make(map[int][]int, len(rows))
for _, batch := range chunkInts(ids, sqlInChunk) {
var links []model.ClientInbound
if err := db.Where("client_id IN ?", batch).Order("inbound_id ASC").Find(&links).Error; err != nil {
return nil, err
}
for _, l := range links {
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
}
}
for i := range rows {
client := rows[i].ToClient()
// The per-inbound flow_override is the reliable flow for multi-inbound
// clients; the canonical column can be left stale by SyncInbound (#4792).
if flow, err := s.EffectiveFlow(db, rows[i].Id); err == nil && flow != "" {
client.Flow = flow
}
out = append(out, ClientCreatePayload{
Client: *client,
InboundIds: attachments[rows[i].Id],
})
}
return out, nil
}
// ImportClients recreates clients from an exported list. Items that carry
// inboundIds go through the normal BulkCreate path (added to every inbound and
// pushed to xray); items with no inboundIds are restored as bare records so an
// orphan-inclusive export round-trips. Existing emails are never overwritten —
// they are reported in Skipped. The boolean reports whether xray needs a restart.
func (s *ClientService) ImportClients(inboundSvc *InboundService, items []ClientCreatePayload) (BulkCreateResult, bool, error) {
result := BulkCreateResult{}
if len(items) == 0 {
return result, false, nil
}
attached := make([]ClientCreatePayload, 0, len(items))
orphans := make([]ClientCreatePayload, 0)
for i := range items {
if len(items[i].InboundIds) > 0 {
attached = append(attached, items[i])
} else {
orphans = append(orphans, items[i])
}
}
skip := func(email, reason string) {
if strings.TrimSpace(email) == "" {
email = "(missing email)"
}
result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
}
needRestart := false
if len(attached) > 0 {
sub, nr, err := s.BulkCreate(inboundSvc, attached)
if err != nil {
return result, needRestart, err
}
needRestart = needRestart || nr
result.Created += sub.Created
result.Skipped = append(result.Skipped, sub.Skipped...)
}
db := database.GetDB()
for i := range orphans {
client := orphans[i].Client
email := strings.TrimSpace(client.Email)
if email == "" {
skip("", "client email is required")
continue
}
if verr := validateClientEmail(email); verr != nil {
skip(email, verr.Error())
continue
}
if verr := validateClientSubID(client.SubID); verr != nil {
skip(email, verr.Error())
continue
}
// An existing record (in the DB or just created from the attached set
// above) always wins — import never clobbers a live client.
var taken int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&taken).Error; err != nil {
return result, needRestart, err
}
if taken > 0 {
skip(email, "email already in use: "+email)
continue
}
client.Email = email
if client.SubID == "" {
client.SubID = uuid.NewString()
}
if client.SubID != "" {
var subTaken int64
if err := db.Model(&model.ClientRecord{}).
Where("sub_id = ? AND email <> ?", client.SubID, email).
Count(&subTaken).Error; err != nil {
return result, needRestart, err
}
if subTaken > 0 {
skip(email, "subId already in use: "+client.SubID)
continue
}
}
if !client.Enable {
client.Enable = true
}
now := time.Now().UnixMilli()
if client.CreatedAt == 0 {
client.CreatedAt = now
}
client.UpdatedAt = now
if err := db.Create(client.ToRecord()).Error; err != nil {
skip(email, err.Error())
continue
}
result.Created++
}
return result, needRestart, nil
}
// DeleteOrphans removes every client that is not attached to any inbound,
// together with its traffic rows, IP log, and external links. It mirrors the
// cleanup the single-client Delete performs, batched into one transaction.
// Returns the number of clients deleted.
func (s *ClientService) DeleteOrphans() (int, error) {
db := database.GetDB()
sub := database.GetDB().Table("client_inbounds").Select("client_id")
var rows []model.ClientRecord
if err := db.Where("id NOT IN (?)", sub).Order("id ASC").Find(&rows).Error; err != nil {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
ids := make([]int, 0, len(rows))
emails := make([]string, 0, len(rows))
for i := range rows {
ids = append(ids, rows[i].Id)
if rows[i].Email != "" {
emails = append(emails, rows[i].Email)
}
}
tombstoneClientEmails(emails)
if err := runSerializedTx(func(tx *gorm.DB) error {
for _, batch := range chunkInts(ids, sqlInChunk) {
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
return e
}
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientExternalLink{}).Error; e != nil {
return e
}
}
if len(emails) > 0 {
for _, batch := range chunkStrings(emails, sqlInChunk) {
if e := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; e != nil {
return e
}
if e := tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; e != nil {
return e
}
}
if e := clearGlobalTraffic(tx, emails...); e != nil {
return e
}
}
for _, batch := range chunkInts(ids, sqlInChunk) {
if e := tx.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; e != nil {
return e
}
}
return nil
}); err != nil {
return 0, err
}
return len(ids), nil
}
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "حذف المنتهية",
"delDepletedConfirmTitle": "حذف العملاء المنتهية حصصهم؟",
"delDepletedConfirmContent": "يُحذف كل عميل استُنفِدت حصة حركة مروره أو انتهت صلاحيته. لا يمكن التراجع.",
"exportClients": "تصدير العملاء",
"importClients": "استيراد العملاء",
"import": "استيراد",
"delOrphans": "حذف العملاء غير المرتبطين",
"delOrphansConfirmTitle": "حذف العملاء بلا اتصال وارد؟",
"delOrphansConfirmContent": "يزيل كل عميل غير مرتبط بأي اتصال وارد مع سجل حركة مروره. لا يمكن التراجع.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}",
"bulkAdjusted": "تم تعديل {count} عميل",
"bulkAdjustedMixed": "{ok} تم تعديلهم، {skipped} تم تخطيهم",
"delDepleted": "تم حذف {count} عميل منتهٍ"
"delDepleted": "تم حذف {count} عميل منتهٍ",
"delOrphans": "تم حذف {count} عميل غير مرتبط",
"imported": "تم استيراد {count} عميل",
"importedMixed": "{ok} تم استيرادهم، {failed} تم تخطيهم"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "Delete depleted",
"delDepletedConfirmTitle": "Delete depleted clients?",
"delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
"exportClients": "Export clients",
"importClients": "Import clients",
"import": "Import",
"delOrphans": "Delete unattached clients",
"delOrphansConfirmTitle": "Delete clients without an inbound?",
"delOrphansConfirmContent": "Removes every client that is not attached to any inbound, along with its traffic record. This cannot be undone.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "{ok} created, {failed} failed",
"bulkAdjusted": "{count} clients adjusted",
"bulkAdjustedMixed": "{ok} adjusted, {skipped} skipped",
"delDepleted": "{count} depleted clients deleted"
"delDepleted": "{count} depleted clients deleted",
"delOrphans": "{count} unattached clients deleted",
"imported": "{count} clients imported",
"importedMixed": "{ok} imported, {failed} skipped"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "Eliminar agotados",
"delDepletedConfirmTitle": "¿Eliminar clientes agotados?",
"delDepletedConfirmContent": "Elimina todos los clientes con cuota agotada o expirados. No se puede deshacer.",
"exportClients": "Exportar clientes",
"importClients": "Importar clientes",
"import": "Importar",
"delOrphans": "Eliminar clientes sin entrante",
"delOrphansConfirmTitle": "¿Eliminar clientes sin entrante?",
"delOrphansConfirmContent": "Elimina todos los clientes que no están vinculados a ningún entrante, junto con su registro de tráfico. No se puede deshacer.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "{ok} creados, {failed} fallidos",
"bulkAdjusted": "{count} clientes ajustados",
"bulkAdjustedMixed": "{ok} ajustados, {skipped} omitidos",
"delDepleted": "{count} clientes agotados eliminados"
"delDepleted": "{count} clientes agotados eliminados",
"delOrphans": "{count} clientes sin entrante eliminados",
"imported": "{count} clientes importados",
"importedMixed": "{ok} importados, {failed} omitidos"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "حذف اتمام‌یافته‌ها",
"delDepletedConfirmTitle": "حذف کلاینت‌های اتمام‌یافته؟",
"delDepletedConfirmContent": "هر کلاینتی که سهمیه ترافیک‌اش تمام شده یا تاریخ انقضایش گذشته است حذف می‌شود. این عمل غیرقابل بازگشت است.",
"exportClients": "خروجی گرفتن از کلاینت‌ها",
"importClients": "ورود کلاینت‌ها",
"import": "ورود",
"delOrphans": "حذف کلاینت‌های بدون اینباند",
"delOrphansConfirmTitle": "حذف کلاینت‌های بدون اینباند؟",
"delOrphansConfirmContent": "هر کلاینتی که به هیچ اینباندی متصل نیست، همراه با رکورد ترافیک‌اش حذف می‌شود. این عمل غیرقابل بازگشت است.",
"auth": "احراز",
"hysteriaAuth": "احراز Hysteria",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق",
"bulkAdjusted": "{count} کلاینت تنظیم شد",
"bulkAdjustedMixed": "{ok} تنظیم، {skipped} رد شد",
"delDepleted": "{count} کلاینت اتمام‌یافته حذف شد"
"delDepleted": "{count} کلاینت اتمام‌یافته حذف شد",
"delOrphans": "{count} کلاینت بدون اینباند حذف شد",
"imported": "{count} کلاینت وارد شد",
"importedMixed": "{ok} وارد شد، {failed} رد شد"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "Hapus yang habis",
"delDepletedConfirmTitle": "Hapus klien yang habis?",
"delDepletedConfirmContent": "Hapus setiap klien yang kuota lalu lintasnya habis atau yang masa berlakunya telah berakhir. Tidak dapat dibatalkan.",
"exportClients": "Ekspor klien",
"importClients": "Impor klien",
"import": "Impor",
"delOrphans": "Hapus klien tanpa inbound",
"delOrphansConfirmTitle": "Hapus klien tanpa inbound?",
"delOrphansConfirmContent": "Menghapus setiap klien yang tidak terhubung ke inbound mana pun, beserta catatan lalu lintasnya. Tidak dapat dibatalkan.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
"bulkAdjusted": "{count} klien disesuaikan",
"bulkAdjustedMixed": "{ok} disesuaikan, {skipped} dilewati",
"delDepleted": "{count} klien habis dihapus"
"delDepleted": "{count} klien habis dihapus",
"delOrphans": "{count} klien tanpa inbound dihapus",
"imported": "{count} klien diimpor",
"importedMixed": "{ok} diimpor, {failed} dilewati"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "使い切ったクライアントを削除",
"delDepletedConfirmTitle": "使い切ったクライアントを削除しますか?",
"delDepletedConfirmContent": "トラフィック上限に達したか有効期限が切れたクライアントをすべて削除します。元に戻せません。",
"exportClients": "クライアントをエクスポート",
"importClients": "クライアントをインポート",
"import": "インポート",
"delOrphans": "未アタッチのクライアントを削除",
"delOrphansConfirmTitle": "インバウンドのないクライアントを削除しますか?",
"delOrphansConfirmContent": "どのインバウンドにもアタッチされていないクライアントを、そのトラフィック記録とともにすべて削除します。元に戻せません。",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗",
"bulkAdjusted": "{count} 件のクライアントを調整しました",
"bulkAdjustedMixed": "{ok} 件調整、{skipped} 件スキップ",
"delDepleted": "使い切った {count} 件のクライアントを削除しました"
"delDepleted": "使い切った {count} 件のクライアントを削除しました",
"delOrphans": "未アタッチの {count} 件のクライアントを削除しました",
"imported": "{count} 件のクライアントをインポートしました",
"importedMixed": "{ok} 件インポート、{failed} 件スキップ"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "Excluir esgotados",
"delDepletedConfirmTitle": "Excluir clientes esgotados?",
"delDepletedConfirmContent": "Remove todos os clientes cuja cota de tráfego foi esgotada ou cuja expiração já passou. Não é possível desfazer.",
"exportClients": "Exportar clientes",
"importClients": "Importar clientes",
"import": "Importar",
"delOrphans": "Excluir clientes sem inbound",
"delOrphansConfirmTitle": "Excluir clientes sem inbound?",
"delOrphansConfirmContent": "Remove todos os clientes que não estão vinculados a nenhum inbound, junto com seu registro de tráfego. Não é possível desfazer.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "{ok} criados, {failed} com falha",
"bulkAdjusted": "{count} clientes ajustados",
"bulkAdjustedMixed": "{ok} ajustados, {skipped} ignorados",
"delDepleted": "{count} clientes esgotados excluídos"
"delDepleted": "{count} clientes esgotados excluídos",
"delOrphans": "{count} clientes sem inbound excluídos",
"imported": "{count} clientes importados",
"importedMixed": "{ok} importados, {failed} ignorados"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "Удалить исчерпанных",
"delDepletedConfirmTitle": "Удалить исчерпанных клиентов?",
"delDepletedConfirmContent": "Удаляются все клиенты, у которых исчерпана квота трафика или истёк срок. Это действие нельзя отменить.",
"exportClients": "Экспортировать клиентов",
"importClients": "Импортировать клиентов",
"import": "Импорт",
"delOrphans": "Удалить клиентов без входящего",
"delOrphansConfirmTitle": "Удалить клиентов без входящего?",
"delOrphansConfirmContent": "Удаляются все клиенты, не привязанные ни к одному входящему, вместе с их записями трафика. Это действие нельзя отменить.",
"auth": "Авторизация",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}",
"bulkAdjusted": "Изменено клиентов: {count}",
"bulkAdjustedMixed": "Изменено: {ok}, пропущено: {skipped}",
"delDepleted": "Удалено исчерпанных клиентов: {count}"
"delDepleted": "Удалено исчерпанных клиентов: {count}",
"delOrphans": "Удалено клиентов без входящего: {count}",
"imported": "Импортировано клиентов: {count}",
"importedMixed": "Импортировано: {ok}, пропущено: {failed}"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "Süresi/Kotası Bitenleri Sil",
"delDepletedConfirmTitle": "Tükenmiş Kullanıcılar Silinsin Mi?",
"delDepletedConfirmContent": "Trafik kotası dolan veya süresi geçen tüm kullanıcılar silinir. Geri alınamaz.",
"exportClients": "Kullanıcıları Dışa Aktar",
"importClients": "Kullanıcıları İçe Aktar",
"import": "İçe Aktar",
"delOrphans": "Bağsız Kullanıcıları Sil",
"delOrphansConfirmTitle": "Gelen Bağlantısı Olmayan Kullanıcılar Silinsin Mi?",
"delOrphansConfirmContent": "Hiçbir gelen bağlantıya bağlı olmayan her kullanıcı, trafik kaydıyla birlikte silinir. Geri alınamaz.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
"bulkAdjusted": "{count} kullanıcı ayarlandı",
"bulkAdjustedMixed": "{ok} ayarlandı, {skipped} atlandı",
"delDepleted": "{count} tükenmiş kullanıcı silindi"
"delDepleted": "{count} tükenmiş kullanıcı silindi",
"delOrphans": "{count} bağsız kullanıcı silindi",
"imported": "{count} kullanıcı içe aktarıldı",
"importedMixed": "{ok} içe aktarıldı, {failed} atlandı"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "Видалити вичерпаних",
"delDepletedConfirmTitle": "Видалити вичерпаних клієнтів?",
"delDepletedConfirmContent": "Видаляються всі клієнти, у яких вичерпана квота трафіку або сплив термін. Цю дію неможливо скасувати.",
"exportClients": "Експортувати клієнтів",
"importClients": "Імпортувати клієнтів",
"import": "Імпорт",
"delOrphans": "Видалити клієнтів без вхідного",
"delOrphansConfirmTitle": "Видалити клієнтів без вхідного?",
"delOrphansConfirmContent": "Видаляється кожен клієнт, не прив'язаний до жодного вхідного, разом із його записом трафіку. Цю дію неможливо скасувати.",
"auth": "Авторизація",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}",
"bulkAdjusted": "Змінено клієнтів: {count}",
"bulkAdjustedMixed": "Змінено: {ok}, пропущено: {skipped}",
"delDepleted": "Видалено вичерпаних клієнтів: {count}"
"delDepleted": "Видалено вичерпаних клієнтів: {count}",
"delOrphans": "Видалено клієнтів без вхідного: {count}",
"imported": "Імпортовано клієнтів: {count}",
"importedMixed": "Імпортовано: {ok}, пропущено: {failed}"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "Xóa hết hạn mức",
"delDepletedConfirmTitle": "Xóa khách hàng hết hạn mức?",
"delDepletedConfirmContent": "Gỡ tất cả khách hàng đã dùng hết hạn mức lưu lượng hoặc đã quá hạn. Không thể hoàn tác.",
"exportClients": "Xuất khách hàng",
"importClients": "Nhập khách hàng",
"import": "Nhập",
"delOrphans": "Xóa khách hàng không gắn inbound",
"delOrphansConfirmTitle": "Xóa khách hàng không thuộc inbound nào?",
"delOrphansConfirmContent": "Gỡ tất cả khách hàng không được gắn vào bất kỳ inbound nào, cùng với bản ghi lưu lượng của họ. Không thể hoàn tác.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}",
"bulkAdjusted": "Đã điều chỉnh {count} khách hàng",
"bulkAdjustedMixed": "Đã điều chỉnh {ok}, bỏ qua {skipped}",
"delDepleted": "Đã xóa {count} khách hàng hết hạn mức"
"delDepleted": "Đã xóa {count} khách hàng hết hạn mức",
"delOrphans": "Đã xóa {count} khách hàng không gắn inbound",
"imported": "Đã nhập {count} khách hàng",
"importedMixed": "Đã nhập {ok}, bỏ qua {failed}"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "删除已耗尽",
"delDepletedConfirmTitle": "删除已耗尽的客户端?",
"delDepletedConfirmContent": "删除所有流量配额已用尽或已过期的客户端。该操作不可撤销。",
"exportClients": "导出客户端",
"importClients": "导入客户端",
"import": "导入",
"delOrphans": "删除未关联的客户端",
"delOrphansConfirmTitle": "删除没有入站的客户端?",
"delOrphansConfirmContent": "删除所有未关联到任何入站的客户端及其流量记录。该操作不可撤销。",
"auth": "认证",
"hysteriaAuth": "Hysteria 认证",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个",
"bulkAdjusted": "已调整 {count} 个客户端",
"bulkAdjustedMixed": "已调整 {ok} 个,跳过 {skipped} 个",
"delDepleted": "已删除 {count} 个已耗尽的客户端"
"delDepleted": "已删除 {count} 个已耗尽的客户端",
"delOrphans": "已删除 {count} 个未关联的客户端",
"imported": "已导入 {count} 个客户端",
"importedMixed": "已导入 {ok} 个,跳过 {failed} 个"
}
},
"groups": {
+10 -1
View File
@@ -828,6 +828,12 @@
"delDepleted": "刪除已耗盡",
"delDepletedConfirmTitle": "刪除已耗盡的客戶端?",
"delDepletedConfirmContent": "刪除所有流量配額已用盡或已過期的客戶端。此操作無法復原。",
"exportClients": "匯出客戶端",
"importClients": "匯入客戶端",
"import": "匯入",
"delOrphans": "刪除未關聯的客戶端",
"delOrphansConfirmTitle": "刪除沒有入站的客戶端?",
"delOrphansConfirmContent": "移除所有未關聯任何入站的客戶端,連同其流量紀錄一併刪除。此操作無法復原。",
"auth": "認證",
"hysteriaAuth": "Hysteria 認證",
"uuid": "UUID",
@@ -850,7 +856,10 @@
"bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個",
"bulkAdjusted": "已調整 {count} 個客戶端",
"bulkAdjustedMixed": "已調整 {ok} 個,跳過 {skipped} 個",
"delDepleted": "已刪除 {count} 個已耗盡的客戶端"
"delDepleted": "已刪除 {count} 個已耗盡的客戶端",
"delOrphans": "已刪除 {count} 個未關聯的客戶端",
"imported": "已匯入 {count} 個客戶端",
"importedMixed": "已匯入 {ok} 個,跳過 {failed} 個"
}
},
"groups": {