diff options
| author | Rs.Nest <css81933@gmail.com> | 2026-04-23 16:19:07 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-23 16:19:07 +0300 |
| commit | 6bcaf61c44f7d51664dc12a942877b7d7c8d5464 (patch) | |
| tree | b46a21be6558cb141f478dd4b1f74f1338ffc086 /web | |
| parent | ff250726901b2eced76c142a35111d872414bbe9 (diff) | |
Feature: Copy clients between inbounds (#4087)
* feat: copy clients between inbounds
* fix: copy clients modal not opening
* fix: copy clients modal not opening
* fix: copy clients modal not opening
* fix: copy clients modal not opening
* fix: copy clients modal not opening
* fix: copy clients modal not opening
* fix: copy clients modal not opening
* fix: copy clients modal not opening
* fix: copy clients modal not opening
* revert: undo install.sh/deploy.sh changes; i18n: add copy-clients translations for all languages
---------
Co-authored-by: Нестеров Руслан <r.nesterov@comagic.dev>
Diffstat (limited to 'web')
| -rw-r--r-- | web/controller/inbound.go | 37 | ||||
| -rw-r--r-- | web/html/inbounds.html | 219 | ||||
| -rw-r--r-- | web/service/inbound.go | 203 | ||||
| -rw-r--r-- | web/translation/translate.ar_EG.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.en_US.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.es_ES.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.fa_IR.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.id_ID.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.ja_JP.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.pt_BR.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.ru_RU.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.tr_TR.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.uk_UA.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.vi_VN.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.zh_CN.toml | 14 | ||||
| -rw-r--r-- | web/translation/translate.zh_TW.toml | 14 |
16 files changed, 641 insertions, 0 deletions
diff --git a/web/controller/inbound.go b/web/controller/inbound.go index b012ec95..ee024cc6 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -41,6 +41,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.POST("/clientIps/:email", a.getClientIps) g.POST("/clearClientIps/:email", a.clearClientIps) g.POST("/addClient", a.addInboundClient) + g.POST("/:id/copyClients", a.copyInboundClients) g.POST("/:id/delClient/:clientId", a.delInboundClient) g.POST("/updateClient/:clientId", a.updateInboundClient) g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic) @@ -54,6 +55,12 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail) } +type CopyInboundClientsRequest struct { + SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"` + ClientEmails []string `form:"clientEmails" json:"clientEmails"` + Flow string `form:"flow" json:"flow"` +} + // getInbounds retrieves the list of inbounds for the logged-in user. func (a *InboundController) getInbounds(c *gin.Context) { user := session.GetLoginUser(c) @@ -260,6 +267,36 @@ func (a *InboundController) addInboundClient(c *gin.Context) { } } +// copyInboundClients copies clients from source inbound to target inbound. +func (a *InboundController) copyInboundClients(c *gin.Context) { + targetID, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + req := &CopyInboundClientsRequest{} + err = c.ShouldBind(req) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + if req.SourceInboundID <= 0 { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id")) + return + } + + result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, result, nil) + if needRestart { + a.xrayService.SetToNeedRestart() + } +} + // delInboundClient deletes a client from an inbound by inbound ID and client ID. func (a *InboundController) delInboundClient(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) diff --git a/web/html/inbounds.html b/web/html/inbounds.html index 60de0750..b8485702 100644 --- a/web/html/inbounds.html +++ b/web/html/inbounds.html @@ -262,6 +262,10 @@ <a-icon type="usergroup-add"></a-icon> {{ i18n "pages.client.bulk"}} </a-menu-item> + <a-menu-item key="copyClients"> + <a-icon type="copy"></a-icon> + {{ i18n "pages.client.copyFromInbound"}} + </a-menu-item> <a-menu-item key="resetClients"> <a-icon type="file-done"></a-icon> {{ i18n @@ -777,6 +781,218 @@ {{template "modals/inboundInfoModal"}} {{template "modals/clientsModal"}} {{template "modals/clientsBulkModal"}} +<a-modal id="copy-clients-modal" + :title="copyClientsModal.title" + :visible="copyClientsModal.visible" + :confirm-loading="copyClientsModal.confirmLoading" + ok-text='{{ i18n "pages.client.copySelected" }}' + cancel-text='{{ i18n "close" }}' + :class="themeSwitcher.currentTheme" + :closable="true" + :mask-closable="false" + @ok="() => copyClientsModal.ok()" + @cancel="() => copyClientsModal.close()" + width="900px"> + <a-space direction="vertical" style="width: 100%;"> + <div> + <div style="margin-bottom: 6px;">{{ i18n "pages.client.copySource" }}</div> + <a-select v-model="copyClientsModal.sourceInboundId" + style="width: 100%;" + :dropdown-class-name="themeSwitcher.currentTheme" + @change="id => copyClientsModal.onSourceChange(id)"> + <a-select-option v-for="item in copyClientsModal.sources" + :key="item.id" + :value="item.id"> + [[ item.label ]] + </a-select-option> + </a-select> + </div> + <div v-if="copyClientsModal.sourceInboundId"> + <a-space style="margin-bottom: 10px;"> + <a-button size="small" @click="() => copyClientsModal.selectAll()">{{ i18n "pages.client.selectAll" }}</a-button> + <a-button size="small" @click="() => copyClientsModal.clearAll()">{{ i18n "pages.client.clearAll" }}</a-button> + </a-space> + <a-table :columns="copyClientsColumns" + :data-source="copyClientsModal.sourceClients" + :pagination="false" + size="small" + :row-key="item => item.email" + :scroll="{ y: 280 }"> + <template slot="emailCheckbox" slot-scope="text, record"> + <a-checkbox :checked="copyClientsModal.selectedEmails.includes(record.email)" + @change="event => copyClientsModal.toggleEmail(record.email, event.target.checked)"> + [[ record.email ]] + </a-checkbox> + </template> + </a-table> + </div> + <div v-if="copyClientsModal.showFlow"> + <div style="margin-bottom: 6px;">{{ i18n "pages.client.copyFlowLabel" }}</div> + <a-select v-model="copyClientsModal.flow" + style="width: 100%;" + :dropdown-class-name="themeSwitcher.currentTheme" + allow-clear> + <a-select-option value="">{{ i18n "none" }}</a-select-option> + <a-select-option value="xtls-rprx-vision">xtls-rprx-vision</a-select-option> + <a-select-option value="xtls-rprx-vision-udp443">xtls-rprx-vision-udp443</a-select-option> + </a-select> + <div style="margin-top: 4px; font-size: 12px; opacity: 0.7;"> + {{ i18n "pages.client.copyFlowHint" }} + </div> + </div> + <div v-if="copyClientsModal.selectedEmails.length > 0"> + <div style="margin-bottom: 4px;">{{ i18n "pages.client.copyEmailPreview" }}</div> + <div style="max-height: 120px; overflow-y: auto;"> + <a-tag v-for="preview in previewEmails" :key="preview" style="margin-bottom: 4px;"> + [[ preview ]] + </a-tag> + </div> + </div> + </a-space> +</a-modal> +<script> + const copyClientsColumns = [ + { title: '{{ i18n "pages.inbounds.email" }}', width: 300, scopedSlots: { customRender: 'emailCheckbox' } }, + { title: '{{ i18n "pages.inbounds.traffic" }}', width: 160, dataIndex: 'trafficLabel' }, + { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, dataIndex: 'expiryLabel' }, + ]; + + const copyClientsModal = { + visible: false, + confirmLoading: false, + title: '', + targetInboundId: 0, + targetInboundRemark: '', + targetProtocol: '', + showFlow: false, + flow: '', + sourceInboundId: undefined, + sources: [], + sourceClients: [], + selectedEmails: [], + show(targetDbInbound) { + if (!targetDbInbound) return; + const sources = app.dbInbounds + .filter(row => row.id !== targetDbInbound.id && typeof row.isMultiUser === 'function' && row.isMultiUser()) + .map(row => { + const clients = app.getInboundClients(row) || []; + return { id: row.id, label: `${row.remark} (${row.protocol}, ${clients.length})` }; + }); + let showFlow = false; + try { + const targetInbound = targetDbInbound.toInbound(); + showFlow = !!(targetInbound && typeof targetInbound.canEnableTlsFlow === 'function' && targetInbound.canEnableTlsFlow()); + } catch (e) { + showFlow = false; + } + copyClientsModal.targetInboundId = targetDbInbound.id; + copyClientsModal.targetInboundRemark = targetDbInbound.remark; + copyClientsModal.targetProtocol = targetDbInbound.protocol; + copyClientsModal.showFlow = showFlow; + copyClientsModal.flow = ''; + copyClientsModal.title = `{{ i18n "pages.client.copyToInbound" }} ${targetDbInbound.remark}`; + copyClientsModal.sources = sources; + copyClientsModal.sourceInboundId = undefined; + copyClientsModal.sourceClients = []; + copyClientsModal.selectedEmails = []; + copyClientsModal.confirmLoading = false; + copyClientsModal.visible = true; + }, + close() { + copyClientsModal.visible = false; + copyClientsModal.confirmLoading = false; + }, + onSourceChange(sourceInboundId) { + copyClientsModal.selectedEmails = []; + const sourceInbound = app.dbInbounds.find(row => row.id === Number(sourceInboundId)); + if (!sourceInbound) { + copyClientsModal.sourceClients = []; + return; + } + const sourceClients = app.getInboundClients(sourceInbound) || []; + copyClientsModal.sourceClients = sourceClients.map(client => { + const stats = app.getClientStats(sourceInbound, client.email); + const used = stats ? ((stats.up || 0) + (stats.down || 0)) : 0; + let expiryLabel = '{{ i18n "unlimited" }}'; + if (client.expiryTime > 0) { + expiryLabel = IntlUtil.formatDate(client.expiryTime); + } else if (client.expiryTime < 0) { + expiryLabel = `${-client.expiryTime / 86400000}d`; + } + return { + email: client.email, + trafficLabel: SizeFormatter.sizeFormat(used), + expiryLabel, + }; + }); + }, + toggleEmail(email, checked) { + const selected = copyClientsModal.selectedEmails.slice(); + if (checked) { + if (!selected.includes(email)) selected.push(email); + } else { + const idx = selected.indexOf(email); + if (idx >= 0) selected.splice(idx, 1); + } + copyClientsModal.selectedEmails = selected; + }, + selectAll() { + copyClientsModal.selectedEmails = copyClientsModal.sourceClients.map(item => item.email); + }, + clearAll() { + copyClientsModal.selectedEmails = []; + }, + async ok() { + if (!copyClientsModal.sourceInboundId) { + app.$message.error('{{ i18n "pages.client.copySelectSourceFirst" }}'); + return; + } + copyClientsModal.confirmLoading = true; + const payload = { + sourceInboundId: copyClientsModal.sourceInboundId, + clientEmails: copyClientsModal.selectedEmails, + }; + if (copyClientsModal.showFlow && copyClientsModal.flow) { + payload.flow = copyClientsModal.flow; + } + try { + const msg = await HttpUtil.post(`/panel/api/inbounds/${copyClientsModal.targetInboundId}/copyClients`, payload); + if (!msg || !msg.success) return; + const obj = msg.obj || {}; + const addedCount = (obj.added || []).length; + const errorList = obj.errors || []; + if (addedCount > 0) { + app.$message.success(`{{ i18n "pages.client.copyResultSuccess" }}: ${addedCount}`); + } else { + app.$message.warning('{{ i18n "pages.client.copyResultNone" }}'); + } + if (errorList.length > 0) { + app.$message.error(`{{ i18n "pages.client.copyResultErrors" }}: ${errorList.join('; ')}`); + } + copyClientsModal.close(); + await app.getDBInbounds(); + } finally { + copyClientsModal.confirmLoading = false; + } + }, + }; + + const copyClientsModalApp = new Vue({ + delimiters: ['[[', ']]'], + el: '#copy-clients-modal', + data: { + copyClientsModal, + copyClientsColumns, + themeSwitcher, + }, + computed: { + previewEmails() { + if (!this.copyClientsModal.targetInboundId) return []; + return this.copyClientsModal.selectedEmails.map(email => `${email}_${this.copyClientsModal.targetInboundId}`); + }, + }, + }); +</script> <script> const columns = [{ title: "ID", @@ -1135,6 +1351,9 @@ case "addBulkClient": this.openAddBulkClient(dbInbound.id) break; + case "copyClients": + copyClientsModal.show(dbInbound); + break; case "export": this.inboundLinks(dbInbound.id); break; diff --git a/web/service/inbound.go b/web/service/inbound.go index 19c3d80c..7d5d8932 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" @@ -26,6 +27,12 @@ type InboundService struct { xrayApi xray.XrayAPI } +type CopyClientsResult struct { + Added []string `json:"added"` + Skipped []string `json:"skipped"` + Errors []string `json:"errors"` +} + // GetInbounds retrieves all inbounds for a specific user. // Returns a slice of inbound models with their associated client statistics. func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) { @@ -750,6 +757,202 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) { return needRestart, tx.Save(oldInbound).Error } +func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string { + switch protocol { + case model.Trojan: + return client.Password + case model.Shadowsocks: + return client.Email + case model.Hysteria: + return client.Auth + default: + return client.ID + } +} + +func (s *InboundService) writeBackClientSubID(sourceInboundID int, sourceProtocol model.Protocol, client model.Client, subID string) (bool, error) { + client.SubID = subID + client.UpdatedAt = time.Now().UnixMilli() + clientID := s.getClientPrimaryKey(sourceProtocol, client) + if clientID == "" { + return false, common.NewError("empty client ID") + } + + settingsBytes, err := json.Marshal(map[string][]model.Client{ + "clients": []model.Client{client}, + }) + if err != nil { + return false, err + } + + updatePayload := &model.Inbound{ + Id: sourceInboundID, + Settings: string(settingsBytes), + } + return s.UpdateInboundClient(updatePayload, clientID) +} + +func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string { + switch targetProtocol { + case model.VMESS, model.VLESS: + return uuid.NewString() + default: + return strings.ReplaceAll(uuid.NewString(), "-", "") + } +} + +func (s *InboundService) buildTargetClientFromSource(source model.Client, targetProtocol model.Protocol, email string, flow string) (model.Client, error) { + nowTs := time.Now().UnixMilli() + target := source + target.Email = email + target.CreatedAt = nowTs + target.UpdatedAt = nowTs + + target.ID = "" + target.Password = "" + target.Auth = "" + target.Flow = "" + + switch targetProtocol { + case model.VMESS: + target.ID = s.generateRandomCredential(targetProtocol) + case model.VLESS: + target.ID = s.generateRandomCredential(targetProtocol) + if flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443" { + target.Flow = flow + } + case model.Trojan, model.Shadowsocks: + target.Password = s.generateRandomCredential(targetProtocol) + case model.Hysteria: + target.Auth = s.generateRandomCredential(targetProtocol) + default: + target.ID = s.generateRandomCredential(targetProtocol) + } + + return target, nil +} + +func (s *InboundService) nextAvailableCopiedEmail(originalEmail string, targetID int, occupied map[string]struct{}) string { + base := fmt.Sprintf("%s_%d", originalEmail, targetID) + candidate := base + suffix := 0 + for { + if _, exists := occupied[strings.ToLower(candidate)]; !exists { + occupied[strings.ToLower(candidate)] = struct{}{} + return candidate + } + suffix++ + candidate = fmt.Sprintf("%s_%d", base, suffix) + } +} + +func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID int, clientEmails []string, flow string) (*CopyClientsResult, bool, error) { + result := &CopyClientsResult{ + Added: []string{}, + Skipped: []string{}, + Errors: []string{}, + } + if targetInboundID == sourceInboundID { + return result, false, common.NewError("source and target inbounds must be different") + } + + targetInbound, err := s.GetInbound(targetInboundID) + if err != nil { + return result, false, err + } + sourceInbound, err := s.GetInbound(sourceInboundID) + if err != nil { + return result, false, err + } + + sourceClients, err := s.GetClients(sourceInbound) + if err != nil { + return result, false, err + } + if len(sourceClients) == 0 { + return result, false, nil + } + + allowedEmails := map[string]struct{}{} + if len(clientEmails) > 0 { + for _, email := range clientEmails { + allowedEmails[strings.ToLower(strings.TrimSpace(email))] = struct{}{} + } + } + + occupiedEmails := map[string]struct{}{} + allEmails, err := s.getAllEmails() + if err != nil { + return result, false, err + } + for _, email := range allEmails { + clean := strings.Trim(email, "\"") + if clean != "" { + occupiedEmails[strings.ToLower(clean)] = struct{}{} + } + } + + newClients := make([]model.Client, 0) + needRestart := false + for _, sourceClient := range sourceClients { + originalEmail := strings.TrimSpace(sourceClient.Email) + if originalEmail == "" { + continue + } + if len(allowedEmails) > 0 { + if _, ok := allowedEmails[strings.ToLower(originalEmail)]; !ok { + continue + } + } + + if sourceClient.SubID == "" { + newSubID := uuid.NewString() + subNeedRestart, subErr := s.writeBackClientSubID(sourceInbound.Id, sourceInbound.Protocol, sourceClient, newSubID) + if subErr != nil { + result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to write source subId: %v", originalEmail, subErr)) + continue + } + if subNeedRestart { + needRestart = true + } + sourceClient.SubID = newSubID + } + + targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails) + targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound.Protocol, targetEmail, flow) + if buildErr != nil { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr)) + continue + } + newClients = append(newClients, targetClient) + result.Added = append(result.Added, targetEmail) + } + + if len(newClients) == 0 { + return result, needRestart, nil + } + + settingsPayload, err := json.Marshal(map[string][]model.Client{ + "clients": newClients, + }) + if err != nil { + return result, needRestart, err + } + + addNeedRestart, err := s.AddInboundClient(&model.Inbound{ + Id: targetInboundID, + Settings: string(settingsPayload), + }) + if err != nil { + return result, needRestart, err + } + if addNeedRestart { + needRestart = true + } + + return result, needRestart, nil +} + func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) { oldInbound, err := s.GetInbound(inboundId) if err != nil { diff --git a/web/translation/translate.ar_EG.toml b/web/translation/translate.ar_EG.toml index f9216621..ca7076c8 100644 --- a/web/translation/translate.ar_EG.toml +++ b/web/translation/translate.ar_EG.toml @@ -298,6 +298,20 @@ "submitEdit" = "احفظ التعديلات" "clientCount" = "عدد العملاء" "bulk" = "إضافة بالجملة" +"copyFromInbound" = "نسخ العملاء من الـ Inbound" +"copyToInbound" = "نسخ العملاء إلى" +"copySelected" = "نسخ المحدد" +"copySource" = "المصدر" +"copyEmailPreview" = "معاينة البريد الإلكتروني الناتج" +"copySelectSourceFirst" = "الرجاء اختيار الـ Inbound المصدر أولاً." +"copyResult" = "نتيجة النسخ" +"copyResultSuccess" = "تم النسخ بنجاح" +"copyResultNone" = "لا يوجد شيء للنسخ: لم يتم اختيار أي عميل أو أن المصدر فارغ" +"copyResultErrors" = "أخطاء النسخ" +"copyFlowLabel" = "Flow للعملاء الجدد (VLESS)" +"copyFlowHint" = "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغاً لتخطيه." +"selectAll" = "تحديد الكل" +"clearAll" = "مسح الكل" "method" = "طريقة" "first" = "أول واحد" "last" = "آخر واحد" diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 91580a29..45186187 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -298,6 +298,20 @@ "submitEdit" = "Save Changes" "clientCount" = "Number of Clients" "bulk" = "Add Bulk" +"copyFromInbound" = "Copy Clients from Inbound" +"copyToInbound" = "Copy clients to" +"copySelected" = "Copy Selected" +"copySource" = "Source" +"copyEmailPreview" = "Resulting email preview" +"copySelectSourceFirst" = "Please select a source inbound first." +"copyResult" = "Copy result" +"copyResultSuccess" = "Copied successfully" +"copyResultNone" = "Nothing to copy: no clients selected or source is empty" +"copyResultErrors" = "Copy errors" +"copyFlowLabel" = "Flow for new clients (VLESS)" +"copyFlowHint" = "Applied to all copied clients. Leave empty to skip." +"selectAll" = "Select all" +"clearAll" = "Clear all" "method" = "Method" "first" = "First" "last" = "Last" diff --git a/web/translation/translate.es_ES.toml b/web/translation/translate.es_ES.toml index 3dfe3ade..d8f94461 100644 --- a/web/translation/translate.es_ES.toml +++ b/web/translation/translate.es_ES.toml @@ -298,6 +298,20 @@ "submitEdit" = "Guardar Cambios"
"clientCount" = "Número de Clientes"
"bulk" = "Agregar en Lote"
+"copyFromInbound" = "Copiar clientes desde entrada"
+"copyToInbound" = "Copiar clientes a"
+"copySelected" = "Copiar seleccionados"
+"copySource" = "Origen"
+"copyEmailPreview" = "Vista previa del email resultante"
+"copySelectSourceFirst" = "Seleccione primero una entrada de origen."
+"copyResult" = "Resultado de la copia"
+"copyResultSuccess" = "Copiado correctamente"
+"copyResultNone" = "Nada que copiar: ningún cliente seleccionado o el origen está vacío"
+"copyResultErrors" = "Errores al copiar"
+"copyFlowLabel" = "Flow para nuevos clientes (VLESS)"
+"copyFlowHint" = "Se aplica a todos los clientes copiados. Déjelo vacío para omitir."
+"selectAll" = "Seleccionar todo"
+"clearAll" = "Limpiar todo"
"method" = "Método"
"first" = "Primero"
"last" = "Último"
diff --git a/web/translation/translate.fa_IR.toml b/web/translation/translate.fa_IR.toml index 5569239f..aaec75f6 100644 --- a/web/translation/translate.fa_IR.toml +++ b/web/translation/translate.fa_IR.toml @@ -298,6 +298,20 @@ "submitEdit" = "ذخیره تغییرات" "clientCount" = "تعداد کاربران" "bulk" = "انبوهسازی" +"copyFromInbound" = "کپی کاربران از اینباند" +"copyToInbound" = "کپی کاربران به" +"copySelected" = "کپی انتخابشدهها" +"copySource" = "منبع" +"copyEmailPreview" = "پیشنمایش ایمیل نهایی" +"copySelectSourceFirst" = "ابتدا یک اینباند منبع انتخاب کنید." +"copyResult" = "نتیجه کپی" +"copyResultSuccess" = "با موفقیت کپی شد" +"copyResultNone" = "چیزی برای کپی نیست: هیچ کاربری انتخاب نشده یا منبع خالی است" +"copyResultErrors" = "خطاهای کپی" +"copyFlowLabel" = "Flow برای کاربران جدید (VLESS)" +"copyFlowHint" = "برای همه کاربران کپیشده اعمال میشود. برای نادیده گرفتن، خالی بگذارید." +"selectAll" = "انتخاب همه" +"clearAll" = "پاک کردن همه" "method" = "روش" "first" = "از" "last" = "تا" diff --git a/web/translation/translate.id_ID.toml b/web/translation/translate.id_ID.toml index c53b6a9c..e115aaa8 100644 --- a/web/translation/translate.id_ID.toml +++ b/web/translation/translate.id_ID.toml @@ -298,6 +298,20 @@ "submitEdit" = "Simpan Perubahan" "clientCount" = "Jumlah Klien" "bulk" = "Tambahkan Massal" +"copyFromInbound" = "Salin klien dari inbound" +"copyToInbound" = "Salin klien ke" +"copySelected" = "Salin yang dipilih" +"copySource" = "Sumber" +"copyEmailPreview" = "Pratinjau email hasil" +"copySelectSourceFirst" = "Silakan pilih inbound sumber terlebih dahulu." +"copyResult" = "Hasil penyalinan" +"copyResultSuccess" = "Berhasil disalin" +"copyResultNone" = "Tidak ada yang disalin: tidak ada klien yang dipilih atau sumber kosong" +"copyResultErrors" = "Kesalahan penyalinan" +"copyFlowLabel" = "Flow untuk klien baru (VLESS)" +"copyFlowHint" = "Diterapkan ke semua klien yang disalin. Biarkan kosong untuk melewati." +"selectAll" = "Pilih semua" +"clearAll" = "Hapus semua" "method" = "Metode" "first" = "Pertama" "last" = "Terakhir" diff --git a/web/translation/translate.ja_JP.toml b/web/translation/translate.ja_JP.toml index 2b7cdb63..ffa9168b 100644 --- a/web/translation/translate.ja_JP.toml +++ b/web/translation/translate.ja_JP.toml @@ -298,6 +298,20 @@ "submitEdit" = "変更を保存" "clientCount" = "クライアント数" "bulk" = "一括作成" +"copyFromInbound" = "インバウンドからクライアントをコピー" +"copyToInbound" = "クライアントのコピー先" +"copySelected" = "選択項目をコピー" +"copySource" = "ソース" +"copyEmailPreview" = "結果メールのプレビュー" +"copySelectSourceFirst" = "先にソースインバウンドを選択してください。" +"copyResult" = "コピー結果" +"copyResultSuccess" = "正常にコピーされました" +"copyResultNone" = "コピーする項目がありません: クライアントが選択されていないかソースが空です" +"copyResultErrors" = "コピーエラー" +"copyFlowLabel" = "新規クライアントの Flow (VLESS)" +"copyFlowHint" = "すべてのコピー対象クライアントに適用されます。空のままにするとスキップします。" +"selectAll" = "すべて選択" +"clearAll" = "すべて解除" "method" = "方法" "first" = "最初" "last" = "最後" diff --git a/web/translation/translate.pt_BR.toml b/web/translation/translate.pt_BR.toml index 876e7c57..18db4d62 100644 --- a/web/translation/translate.pt_BR.toml +++ b/web/translation/translate.pt_BR.toml @@ -298,6 +298,20 @@ "submitEdit" = "Salvar Alterações" "clientCount" = "Número de Clientes" "bulk" = "Adicionar Vários" +"copyFromInbound" = "Copiar clientes da entrada" +"copyToInbound" = "Copiar clientes para" +"copySelected" = "Copiar selecionados" +"copySource" = "Origem" +"copyEmailPreview" = "Prévia do email resultante" +"copySelectSourceFirst" = "Selecione primeiro uma entrada de origem." +"copyResult" = "Resultado da cópia" +"copyResultSuccess" = "Copiado com sucesso" +"copyResultNone" = "Nada a copiar: nenhum cliente selecionado ou origem vazia" +"copyResultErrors" = "Erros ao copiar" +"copyFlowLabel" = "Flow para novos clientes (VLESS)" +"copyFlowHint" = "Aplicado a todos os clientes copiados. Deixe em branco para ignorar." +"selectAll" = "Selecionar tudo" +"clearAll" = "Limpar tudo" "method" = "Método" "first" = "Primeiro" "last" = "Último" diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index 0c144ff5..5bf89dfd 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -298,6 +298,20 @@ "submitEdit" = "Сохранить изменения" "clientCount" = "Количество клиентов" "bulk" = "Добавить несколько" +"copyFromInbound" = "Скопировать клиентов из инбаунда" +"copyToInbound" = "Скопировать клиентов в" +"copySelected" = "Скопировать выбранных" +"copySource" = "Источник" +"copyEmailPreview" = "Предпросмотр итоговых email" +"copySelectSourceFirst" = "Сначала выберите источник." +"copyResult" = "Результат копирования" +"copyResultSuccess" = "Успешно скопировано" +"copyResultNone" = "Нечего копировать: ни одного клиента не выбрано или список источника пуст" +"copyResultErrors" = "Ошибки при копировании" +"copyFlowLabel" = "Flow для новых клиентов (VLESS)" +"copyFlowHint" = "Применится ко всем копируемым клиентам. Оставьте пустым, чтобы не задавать." +"selectAll" = "Выбрать всех" +"clearAll" = "Снять всё" "method" = "Метод" "first" = "Первый" "last" = "Последний" diff --git a/web/translation/translate.tr_TR.toml b/web/translation/translate.tr_TR.toml index cc1159a2..0393a7dc 100644 --- a/web/translation/translate.tr_TR.toml +++ b/web/translation/translate.tr_TR.toml @@ -298,6 +298,20 @@ "submitEdit" = "Değişiklikleri Kaydet" "clientCount" = "Müşteri Sayısı" "bulk" = "Toplu Ekle" +"copyFromInbound" = "Gelen bağlantıdan istemcileri kopyala" +"copyToInbound" = "İstemcileri şuraya kopyala" +"copySelected" = "Seçilenleri kopyala" +"copySource" = "Kaynak" +"copyEmailPreview" = "Sonuç e-posta önizlemesi" +"copySelectSourceFirst" = "Önce bir kaynak gelen bağlantı seçin." +"copyResult" = "Kopyalama sonucu" +"copyResultSuccess" = "Başarıyla kopyalandı" +"copyResultNone" = "Kopyalanacak bir şey yok: istemci seçilmedi veya kaynak boş" +"copyResultErrors" = "Kopyalama hataları" +"copyFlowLabel" = "Yeni istemciler için Flow (VLESS)" +"copyFlowHint" = "Kopyalanan tüm istemcilere uygulanır. Boş bırakırsanız atlanır." +"selectAll" = "Tümünü seç" +"clearAll" = "Tümünü temizle" "method" = "Yöntem" "first" = "İlk" "last" = "Son" diff --git a/web/translation/translate.uk_UA.toml b/web/translation/translate.uk_UA.toml index fa1bee62..40bcaa76 100644 --- a/web/translation/translate.uk_UA.toml +++ b/web/translation/translate.uk_UA.toml @@ -298,6 +298,20 @@ "submitEdit" = "Зберегти зміни" "clientCount" = "Кількість клієнтів" "bulk" = "Додати групу" +"copyFromInbound" = "Скопіювати клієнтів з інбаунда" +"copyToInbound" = "Скопіювати клієнтів у" +"copySelected" = "Скопіювати вибраних" +"copySource" = "Джерело" +"copyEmailPreview" = "Попередній перегляд підсумкових email" +"copySelectSourceFirst" = "Спочатку виберіть джерело." +"copyResult" = "Результат копіювання" +"copyResultSuccess" = "Успішно скопійовано" +"copyResultNone" = "Нічого копіювати: жодного клієнта не вибрано або список джерела порожній" +"copyResultErrors" = "Помилки під час копіювання" +"copyFlowLabel" = "Flow для нових клієнтів (VLESS)" +"copyFlowHint" = "Застосується до всіх скопійованих клієнтів. Залиште порожнім, щоб не задавати." +"selectAll" = "Вибрати всіх" +"clearAll" = "Знят
|
