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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user