diff --git a/frontend/src/pages/clients/ClientBulkAddModal.vue b/frontend/src/pages/clients/ClientBulkAddModal.vue index 442d4f16..5b78111e 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.vue +++ b/frontend/src/pages/clients/ClientBulkAddModal.vue @@ -136,10 +136,9 @@ async function submit() { if (emails.length === 0) return; saving.value = true; - let ok = 0; - let failed = 0; + const silentJsonOpts = { ...JSON_HEADERS, silent: true }; try { - for (const email of emails) { + const results = await Promise.all(emails.map((email) => { const client = { email, subId: form.subId || RandomUtil.randomLowerAndNum(16), @@ -154,14 +153,24 @@ async function submit() { enable: true, }; const payload = { client, inboundIds: form.inboundIds }; - const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS); + return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts); + })); + let ok = 0; + let failed = 0; + let firstError = ''; + for (const msg of results) { if (msg?.success) ok++; - else failed++; + else { + failed++; + if (!firstError && msg?.msg) firstError = msg.msg; + } } if (failed === 0) { message.success(t('pages.clients.toasts.bulkCreated', { count: ok })); } else { - message.warning(t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })); + message.warning(firstError + ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}` + : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })); } emit('saved'); close(); diff --git a/frontend/src/pages/clients/ClientFormModal.vue b/frontend/src/pages/clients/ClientFormModal.vue index 7f33f4ca..8f17a254 100644 --- a/frontend/src/pages/clients/ClientFormModal.vue +++ b/frontend/src/pages/clients/ClientFormModal.vue @@ -15,6 +15,7 @@ const props = defineProps({ inbounds: { type: Array, default: () => [] }, attachedIds: { type: Array, default: () => [] }, ipLimitEnable: { type: Boolean, default: false }, + tgBotEnable: { type: Boolean, default: false }, save: { type: Function, required: true }, }); @@ -34,7 +35,9 @@ function emptyForm() { flow: '', reverseTag: '', totalGB: 0, - expiryTime: null, + expiryDate: null, + delayedStart: false, + delayedDays: 0, limitIp: 0, tgId: 0, comment: '', @@ -59,7 +62,16 @@ watch( form.flow = props.client.flow || ''; form.reverseTag = props.client.reverse?.tag || ''; form.totalGB = bytesToGB(props.client.totalGB || 0); - form.expiryTime = props.client.expiryTime ? dayjs(props.client.expiryTime) : null; + const et = Number(props.client.expiryTime) || 0; + if (et < 0) { + form.delayedStart = true; + form.delayedDays = Math.round(et / -86400000); + form.expiryDate = null; + } else { + form.delayedStart = false; + form.delayedDays = 0; + form.expiryDate = et > 0 ? dayjs(et) : null; + } form.limitIp = props.client.limitIp || 0; form.tgId = Number(props.client.tgId) || 0; form.comment = props.client.comment || ''; @@ -186,6 +198,14 @@ function regenerateEmail() { form.email = RandomUtil.randomLowerAndNum(12); } +function onDelayedStartToggle(next) { + if (next) { + form.expiryDate = null; + } else { + form.delayedDays = 0; + } +} + async function onSubmit() { if (!form.email || form.email.trim() === '') { message.error(`${t('pages.clients.email')} *`); @@ -195,6 +215,9 @@ async function onSubmit() { message.error(t('pages.clients.selectInbound')); return; } + const expiryTime = form.delayedStart + ? -86400000 * (Number(form.delayedDays) || 0) + : (form.expiryDate ? form.expiryDate.valueOf() : 0); const clientPayload = { email: form.email.trim(), subId: form.subId, @@ -203,7 +226,7 @@ async function onSubmit() { auth: form.auth, flow: showFlow.value ? (form.flow || '') : '', totalGB: gbToBytes(form.totalGB), - expiryTime: form.expiryTime ? form.expiryTime.valueOf() : 0, + expiryTime, limitIp: Number(form.limitIp) || 0, tgId: Number(form.tgId) || 0, comment: form.comment, @@ -285,7 +308,7 @@ async function onSubmit() { - + @@ -293,7 +316,12 @@ async function onSubmit() { - + + + + + + @@ -302,13 +330,16 @@ async function onSubmit() { - - + + + + + - - + + @@ -330,14 +361,13 @@ async function onSubmit() { - + + :placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" /> - + diff --git a/frontend/src/pages/clients/ClientsPage.vue b/frontend/src/pages/clients/ClientsPage.vue index 00d3f9de..e1a03263 100644 --- a/frontend/src/pages/clients/ClientsPage.vue +++ b/frontend/src/pages/clients/ClientsPage.vue @@ -40,11 +40,13 @@ const { fetched, subSettings, ipLimitEnable, + tgBotEnable, expireDiff, trafficDiff, create, update, remove, + removeMany, attach, detach, resetTraffic, @@ -136,18 +138,24 @@ function onBulkDelete() { okType: 'danger', cancelText: t('cancel'), onOk: async () => { + const results = await removeMany(emails); + selectedRowKeys.value = []; let ok = 0; let failed = 0; - for (const email of emails) { - const msg = await remove(email); + let firstError = ''; + for (const msg of results) { if (msg?.success) ok++; - else failed++; + else { + failed++; + if (!firstError && msg?.msg) firstError = msg.msg; + } } - selectedRowKeys.value = []; if (failed === 0) { message.success(t('pages.clients.toasts.bulkDeleted', { count: ok })); } else { - message.warning(t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })); + message.warning(firstError + ? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}` + : t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })); } }, }); @@ -405,17 +413,26 @@ function remainingColor(row) { } function expiryLabel(row) { - if (!row.expiryTime || row.expiryTime <= 0) return '∞'; + if (!row.expiryTime) return '∞'; + if (row.expiryTime < 0) { + const days = Math.round(row.expiryTime / -86400000); + return `${t('pages.clients.delayedStart')}: ${days}d`; + } return IntlUtil.formatDate(row.expiryTime); } function expiryRelative(row) { - if (!row.expiryTime || row.expiryTime <= 0) return ''; + if (!row.expiryTime) return ''; + if (row.expiryTime < 0) { + const days = Math.round(row.expiryTime / -86400000); + return `${days}d`; + } return IntlUtil.formatRelativeTime(row.expiryTime); } function expiryColor(row) { - if (!row.expiryTime || row.expiryTime <= 0) return 'purple'; + if (!row.expiryTime) return 'purple'; + if (row.expiryTime < 0) return 'blue'; const now = Date.now(); if (row.expiryTime <= now) return 'red'; if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange'; @@ -423,6 +440,7 @@ function expiryColor(row) { } const sortState = ref({ column: null, order: null }); +const paginationState = ref({ current: 1, pageSize: 20 }); function sortableCol(col, key) { return { @@ -465,13 +483,28 @@ const sortedClients = computed(() => { return order === 'descend' ? sorted.reverse() : sorted; }); -function onTableChange(_pag, _filters, sorter) { +function onTableChange(pag, _filters, sorter) { + if (pag) { + paginationState.value = { + current: pag.current || 1, + pageSize: pag.pageSize || paginationState.value.pageSize, + }; + } sortState.value = { column: sorter?.columnKey || sorter?.field || null, order: sorter?.order || null, }; } +const tablePagination = computed(() => ({ + current: paginationState.value.current, + pageSize: paginationState.value.pageSize, + total: sortedClients.value.length, + showSizeChanger: sortedClients.value.length > 10, + pageSizeOptions: ['10', '20', '50', '100'], + hideOnSinglePage: sortedClients.value.length <= paginationState.value.pageSize, +})); + const columns = computed(() => [ { title: t('pages.clients.actions'), key: 'actions', width: 200 }, sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'), @@ -638,9 +671,7 @@ const columns = computed(() => [ + :row-selection="rowSelection" :pagination="tablePagination" size="small" @change="onTableChange">