mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-18 12:05:53 +00:00
feat(inbounds): restore copy-clients-between-inbounds modal
The menu item, backend endpoint (POST /panel/api/inbounds/:id/copyClients), and i18n keys were already in place after the Vue3 migration, but the modal itself was never ported — clicking the menu just toasted "coming soon". Adds CopyClientsModal.vue: source inbound dropdown (multi-user inbounds except the target), per-client checkbox selection via a-table row-selection, optional Flow override when the target supports TLS flow, and result toasts for added/skipped/errors.
This commit is contained in:
parent
fdaa65ad7e
commit
80031e67cc
2 changed files with 192 additions and 4 deletions
185
frontend/src/pages/inbounds/CopyClientsModal.vue
Normal file
185
frontend/src/pages/inbounds/CopyClientsModal.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
|
||||||
|
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
dbInbound: { type: Object, default: null },
|
||||||
|
dbInbounds: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
|
const sourceInboundId = ref(null);
|
||||||
|
const selectedEmails = ref([]);
|
||||||
|
const flow = ref('');
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const sources = computed(() => {
|
||||||
|
if (!props.dbInbound) return [];
|
||||||
|
return props.dbInbounds
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.id !== props.dbInbound.id &&
|
||||||
|
typeof row.isMultiUser === 'function' &&
|
||||||
|
row.isMultiUser(),
|
||||||
|
)
|
||||||
|
.map((row) => {
|
||||||
|
let count = 0;
|
||||||
|
try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
|
||||||
|
return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceInbound = computed(() => {
|
||||||
|
if (!sourceInboundId.value) return null;
|
||||||
|
return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceClients = computed(() => {
|
||||||
|
const sb = sourceInbound.value;
|
||||||
|
if (!sb) return [];
|
||||||
|
let list = [];
|
||||||
|
try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
|
||||||
|
const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
|
||||||
|
return list
|
||||||
|
.filter((c) => c.email)
|
||||||
|
.map((c) => {
|
||||||
|
const s = stats.get(c.email);
|
||||||
|
const used = s ? (s.up || 0) + (s.down || 0) : 0;
|
||||||
|
let expiryLabel = t('unlimited');
|
||||||
|
if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
|
||||||
|
else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
|
||||||
|
return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const showFlow = computed(() => {
|
||||||
|
if (!props.dbInbound) return false;
|
||||||
|
try {
|
||||||
|
const inb = props.dbInbound.toInbound();
|
||||||
|
return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
|
||||||
|
} catch (_e) { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = computed(() => [
|
||||||
|
{ title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
|
||||||
|
{ title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
|
||||||
|
{ title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rowSelection = computed(() => ({
|
||||||
|
selectedRowKeys: selectedEmails.value,
|
||||||
|
onChange: (keys) => { selectedEmails.value = keys; },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (!props.dbInbound) return t('pages.client.copyFromInbound');
|
||||||
|
const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
|
||||||
|
return `${t('pages.client.copyToInbound')} ${target}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
sourceInboundId.value = null;
|
||||||
|
selectedEmails.value = [];
|
||||||
|
flow.value = '';
|
||||||
|
saving.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(sourceInboundId, () => {
|
||||||
|
selectedEmails.value = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
selectedEmails.value = sourceClients.value.map((c) => c.email);
|
||||||
|
}
|
||||||
|
function clearAll() {
|
||||||
|
selectedEmails.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ok() {
|
||||||
|
if (!sourceInboundId.value) {
|
||||||
|
message.error(t('pages.client.copySelectSourceFirst'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!props.dbInbound) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
sourceInboundId: sourceInboundId.value,
|
||||||
|
clientEmails: selectedEmails.value,
|
||||||
|
};
|
||||||
|
if (showFlow.value && flow.value) payload.flow = flow.value;
|
||||||
|
const msg = await HttpUtil.post(
|
||||||
|
`/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (!msg?.success) return;
|
||||||
|
const obj = msg.obj || {};
|
||||||
|
const addedCount = (obj.added || []).length;
|
||||||
|
const errorList = obj.errors || [];
|
||||||
|
if (addedCount > 0) {
|
||||||
|
message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
|
||||||
|
} else {
|
||||||
|
message.warning(t('pages.client.copyResultNone'));
|
||||||
|
}
|
||||||
|
if (errorList.length > 0) {
|
||||||
|
message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
|
||||||
|
}
|
||||||
|
emit('saved');
|
||||||
|
emit('update:open', false);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (saving.value) return;
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
|
||||||
|
:confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
|
||||||
|
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||||
|
<div>
|
||||||
|
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
|
||||||
|
<a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
|
||||||
|
<a-select-option v-for="item in sources" :key="item.id" :value="item.id">
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="sourceInboundId">
|
||||||
|
<a-space :style="{ marginBottom: '8px' }">
|
||||||
|
<a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
|
||||||
|
<a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
|
||||||
|
</a-space>
|
||||||
|
<a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
|
||||||
|
:row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showFlow">
|
||||||
|
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
|
||||||
|
<a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
|
||||||
|
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||||
|
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
|
||||||
|
{{ t('pages.client.copyFlowHint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-space>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
@ -21,6 +21,7 @@ import InboundList from './InboundList.vue';
|
||||||
import InboundFormModal from './InboundFormModal.vue';
|
import InboundFormModal from './InboundFormModal.vue';
|
||||||
import ClientFormModal from './ClientFormModal.vue';
|
import ClientFormModal from './ClientFormModal.vue';
|
||||||
import ClientBulkModal from './ClientBulkModal.vue';
|
import ClientBulkModal from './ClientBulkModal.vue';
|
||||||
|
import CopyClientsModal from './CopyClientsModal.vue';
|
||||||
import InboundInfoModal from './InboundInfoModal.vue';
|
import InboundInfoModal from './InboundInfoModal.vue';
|
||||||
import QrCodeModal from './QrCodeModal.vue';
|
import QrCodeModal from './QrCodeModal.vue';
|
||||||
import TextModal from '@/components/TextModal.vue';
|
import TextModal from '@/components/TextModal.vue';
|
||||||
|
|
@ -88,6 +89,8 @@ const clientIndex = ref(null);
|
||||||
|
|
||||||
const bulkOpen = ref(false);
|
const bulkOpen = ref(false);
|
||||||
const bulkDbInbound = ref(null);
|
const bulkDbInbound = ref(null);
|
||||||
|
const copyOpen = ref(false);
|
||||||
|
const copyDbInbound = ref(null);
|
||||||
|
|
||||||
// === Info / QR-code modals ===========================================
|
// === Info / QR-code modals ===========================================
|
||||||
const infoOpen = ref(false);
|
const infoOpen = ref(false);
|
||||||
|
|
@ -515,10 +518,8 @@ function onRowAction({ key, dbInbound }) {
|
||||||
exportInboundClipboard(dbInbound);
|
exportInboundClipboard(dbInbound);
|
||||||
break;
|
break;
|
||||||
case 'copyClients':
|
case 'copyClients':
|
||||||
// Copy-clients-from-inbound is a tiny dedicated modal in legacy
|
copyDbInbound.value = dbInbound;
|
||||||
// (lets you tick clients to copy across inbounds). Defer to a
|
copyOpen.value = true;
|
||||||
// future commit — surface a friendly message for now.
|
|
||||||
message.info('Copy clients across inbounds — coming soon');
|
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
confirmDelete(dbInbound);
|
confirmDelete(dbInbound);
|
||||||
|
|
@ -663,6 +664,8 @@ function onRowAction({ key, dbInbound }) {
|
||||||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||||
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
||||||
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
||||||
|
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
|
||||||
|
@saved="refresh" />
|
||||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue