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:
MHSanaei
2026-05-11 03:50:28 +02:00
parent e4900f1bd4
commit 6d732d8d32
6 changed files with 231 additions and 14 deletions
+145 -5
View File
@@ -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;