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>
This commit is contained in:
Rs.Nest
2026-04-23 16:19:07 +03:00
committed by GitHub
parent ff25072690
commit 6bcaf61c44
16 changed files with 641 additions and 0 deletions
+219
View File
@@ -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;