Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRs.Nest <css81933@gmail.com>2026-04-23 16:19:07 +0300
committerGitHub <noreply@github.com>2026-04-23 16:19:07 +0300
commit6bcaf61c44f7d51664dc12a942877b7d7c8d5464 (patch)
treeb46a21be6558cb141f478dd4b1f74f1338ffc086 /web/html/inbounds.html
parentff250726901b2eced76c142a35111d872414bbe9 (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/html/inbounds.html')
-rw-r--r--web/html/inbounds.html219
1 files changed, 219 insertions, 0 deletions
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;