mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 13:58:22 +00:00
feat(inbounds): bulk-select clients + UX polish
- ClientBulkModal: add `comment` and VLESS `reverseTag` fields so the bulk-add modal can set them on every generated client (matching the single-client form) - ClientRowTable: add multi-select checkboxes (desktop + mobile) with a tri-state select-all and a sticky bulk-action bar; emits a new `delete-clients` event so the parent can wipe the picked clients in one go. Hidden entirely when the inbound has only one client (the last one must stay) - ClientRowTable: new "Remained" column shows live remaining quota per client (∞ for unlimited, red when depleted) - InboundInfoModal: Remained cell now shows the ∞ tag when the client has no totalGB limit, matching how Total Usage already renders it - InboundsPage: add Online tag (+ per-bucket popovers listing client emails) to the summary card so it mirrors the per-inbound row, and wire an `onDeleteClients` handler that loops the existing single- delete endpoint then refreshes once - InboundList: forward the `delete-clients` event; hide empty remarks on both the desktop table (custom #bodyCell) and the mobile card - useInbounds: aggregate an `online` email list across all inbounds so the summary popover has data to render
This commit is contained in:
parent
e4900f1bd4
commit
6d732d8d32
6 changed files with 231 additions and 14 deletions
|
|
@ -53,6 +53,7 @@ const form = reactive({
|
|||
flow: '',
|
||||
subId: '',
|
||||
tgId: 0,
|
||||
comment: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0, // ms epoch; negative => delayed start days
|
||||
|
|
@ -85,6 +86,7 @@ watch(() => props.open, (next) => {
|
|||
form.flow = '';
|
||||
form.subId = '';
|
||||
form.tgId = 0;
|
||||
form.comment = '';
|
||||
form.limitIp = 0;
|
||||
form.totalGB = 0;
|
||||
form.expiryTime = 0;
|
||||
|
|
@ -135,6 +137,7 @@ function buildClients() {
|
|||
|
||||
if (form.subId.length > 0) c.subId = form.subId;
|
||||
c.tgId = form.tgId;
|
||||
if (form.comment.length > 0) c.comment = form.comment;
|
||||
c.security = form.security;
|
||||
c.limitIp = form.limitIp;
|
||||
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
||||
|
|
@ -227,6 +230,10 @@ async function submit() {
|
|||
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('comment')">
|
||||
<a-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
|
|
@ -39,6 +39,7 @@ const emit = defineEmits([
|
|||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
|
|
@ -162,23 +163,95 @@ function confirmDelete(client) {
|
|||
function rowKey(client) {
|
||||
return client.email || client.id || client.password || JSON.stringify(client);
|
||||
}
|
||||
|
||||
const selected = ref(new Set());
|
||||
|
||||
const allSelected = computed(() =>
|
||||
clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const someSelected = computed(() =>
|
||||
clients.value.some((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const selectedCount = computed(() => selected.value.size);
|
||||
|
||||
function isSelected(key) {
|
||||
return selected.value.has(key);
|
||||
}
|
||||
function toggleSelect(key, next) {
|
||||
const s = new Set(selected.value);
|
||||
if (next) s.add(key); else s.delete(key);
|
||||
selected.value = s;
|
||||
}
|
||||
function selectAll(next) {
|
||||
if (next) {
|
||||
selected.value = new Set(clients.value.map(rowKey));
|
||||
} else {
|
||||
selected.value = new Set();
|
||||
}
|
||||
}
|
||||
function clearSelection() {
|
||||
selected.value = new Set();
|
||||
}
|
||||
|
||||
watch(clients, (list) => {
|
||||
if (selected.value.size === 0) return;
|
||||
const valid = new Set(list.map(rowKey));
|
||||
const next = new Set();
|
||||
for (const k of selected.value) if (valid.has(k)) next.add(k);
|
||||
if (next.size !== selected.value.size) selected.value = next;
|
||||
});
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
|
||||
if (picked.length === 0) return;
|
||||
Modal.confirm({
|
||||
title: t('pages.inbounds.deleteClient') + ` — ${picked.length}`,
|
||||
content: t('pages.inbounds.deleteClientContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => {
|
||||
emit('delete-clients', { dbInbound: props.dbInbound, clients: picked });
|
||||
clearSelection();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
|
||||
<div class="client-list"
|
||||
:class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
|
||||
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
|
||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
|
||||
<a-button size="small" danger @click="confirmBulkDelete">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Desktop: grid table ===================== -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="client-row client-list-header">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
|
||||
@change="(e) => selectAll(e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
||||
<div class="cell cell-enable">{{ t('enable') }}</div>
|
||||
<div class="cell cell-online">{{ t('online') }}</div>
|
||||
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
||||
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
||||
<div class="cell cell-remained">{{ t('remained') }}</div>
|
||||
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
||||
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="client in clients" :key="rowKey(client)" class="client-row">
|
||||
<div v-for="client in clients" :key="rowKey(client)" class="client-row"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">
|
||||
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
||||
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
||||
|
|
@ -262,6 +335,15 @@ function rowKey(client) {
|
|||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-remained">
|
||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-alltime">
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||
</div>
|
||||
|
|
@ -301,8 +383,11 @@ function rowKey(client) {
|
|||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<template v-else>
|
||||
<div v-for="client in clients" :key="rowKey(client)" class="client-card">
|
||||
<div v-for="client in clients" :key="rowKey(client)" class="client-card"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div class="client-card-head">
|
||||
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||
|
|
@ -356,6 +441,15 @@ function rowKey(client) {
|
|||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('remained') }}</span>
|
||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||
|
|
@ -389,8 +483,28 @@ function rowKey(client) {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
}
|
||||
|
||||
.client-row {
|
||||
display: grid;
|
||||
/* Default — no select column (single-client inbounds). The .has-select
|
||||
* modifier below prepends the 40px checkbox column. */
|
||||
grid-template-columns:
|
||||
140px
|
||||
/* actions */
|
||||
|
|
@ -404,6 +518,8 @@ function rowKey(client) {
|
|||
/* traffic */
|
||||
130px
|
||||
/* all-time */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
gap: 12px;
|
||||
|
|
@ -412,6 +528,28 @@ function rowKey(client) {
|
|||
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.client-list.has-select .client-row {
|
||||
grid-template-columns:
|
||||
40px
|
||||
/* select */
|
||||
140px
|
||||
/* actions */
|
||||
60px
|
||||
/* enable */
|
||||
80px
|
||||
/* online */
|
||||
minmax(160px, 2fr)
|
||||
/* client identity */
|
||||
minmax(160px, 2fr)
|
||||
/* traffic */
|
||||
130px
|
||||
/* all-time */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
}
|
||||
|
||||
.client-row:last-child {
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
|
@ -432,10 +570,12 @@ function rowKey(client) {
|
|||
/* allow grid children to shrink instead of overflowing */
|
||||
}
|
||||
|
||||
.cell-select,
|
||||
.cell-actions,
|
||||
.cell-enable,
|
||||
.cell-online,
|
||||
.cell-alltime {
|
||||
.cell-alltime,
|
||||
.cell-remained {
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -387,6 +387,9 @@ const showSubscriptionTab = computed(
|
|||
<td>
|
||||
<a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
|
||||
getRemainingStats() }}</a-tag>
|
||||
<a-tag v-else-if="!clientSettings.totalGB || clientSettings.totalGB <= 0" color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const emit = defineEmits([
|
|||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
|
|
@ -404,6 +405,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
@info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)"
|
||||
@delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -423,6 +425,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)"
|
||||
@delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</template>
|
||||
|
||||
|
|
@ -523,27 +526,35 @@ function showQrCodeMenu(dbInbound) {
|
|||
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
|
||||
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
||||
</a-popover>
|
||||
|
|
|
|||
|
|
@ -322,6 +322,14 @@ async function onDeleteClient({ dbInbound, client }) {
|
|||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
async function onDeleteClients({ dbInbound, clients }) {
|
||||
for (const client of clients) {
|
||||
const clientId = getClientId(dbInbound.protocol, client);
|
||||
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onToggleEnableClient({ dbInbound, client, next }) {
|
||||
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
||||
// client, and post the whole client back through updateClient. This
|
||||
|
|
@ -593,9 +601,38 @@ function onRowAction({ key, dbInbound }) {
|
|||
<a-space direction="horizontal">
|
||||
<TeamOutlined />
|
||||
<a-tag color="green">{{ totals.clients }}</a-tag>
|
||||
<a-tag v-if="totals.deactive.length">{{ totals.deactive.length }}</a-tag>
|
||||
<a-tag v-if="totals.depleted.length" color="red">{{ totals.depleted.length }}</a-tag>
|
||||
<a-tag v-if="totals.expiring.length" color="orange">{{ totals.expiring.length }}</a-tag>
|
||||
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag>{{ totals.deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red">{{ totals.depleted.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange">{{ totals.expiring.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue">{{ totals.online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</a-space>
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
|
|
@ -613,7 +650,7 @@ function onRowAction({ key, dbInbound }) {
|
|||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||
@toggle-enable-client="onToggleEnableClient" />
|
||||
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
|
|
@ -692,3 +729,20 @@ function onRowAction({ key, dbInbound }) {
|
|||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* AD-Vue popovers teleport their content to <body>, so scoped styles
|
||||
don't reach them — this block has to be unscoped. */
|
||||
.client-email-list {
|
||||
max-height: 280px;
|
||||
min-width: 160px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -287,6 +287,7 @@ export function useInbounds() {
|
|||
const deactive = [];
|
||||
const depleted = [];
|
||||
const expiring = [];
|
||||
const online = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
up += ib.up || 0;
|
||||
down += ib.down || 0;
|
||||
|
|
@ -297,9 +298,10 @@ export function useInbounds() {
|
|||
deactive.push(...c.deactive);
|
||||
depleted.push(...c.depleted);
|
||||
expiring.push(...c.expiring);
|
||||
online.push(...c.online);
|
||||
}
|
||||
}
|
||||
return { up, down, allTime, clients, deactive, depleted, expiring };
|
||||
return { up, down, allTime, clients, deactive, depleted, expiring, online };
|
||||
});
|
||||
|
||||
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue