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
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 231 additions and 14 deletions

View file

@ -53,6 +53,7 @@ const form = reactive({
flow: '', flow: '',
subId: '', subId: '',
tgId: 0, tgId: 0,
comment: '',
limitIp: 0, limitIp: 0,
totalGB: 0, totalGB: 0,
expiryTime: 0, // ms epoch; negative => delayed start days expiryTime: 0, // ms epoch; negative => delayed start days
@ -85,6 +86,7 @@ watch(() => props.open, (next) => {
form.flow = ''; form.flow = '';
form.subId = ''; form.subId = '';
form.tgId = 0; form.tgId = 0;
form.comment = '';
form.limitIp = 0; form.limitIp = 0;
form.totalGB = 0; form.totalGB = 0;
form.expiryTime = 0; form.expiryTime = 0;
@ -135,6 +137,7 @@ function buildClients() {
if (form.subId.length > 0) c.subId = form.subId; if (form.subId.length > 0) c.subId = form.subId;
c.tgId = form.tgId; c.tgId = form.tgId;
if (form.comment.length > 0) c.comment = form.comment;
c.security = form.security; c.security = form.security;
c.limitIp = form.limitIp; c.limitIp = form.limitIp;
// Use the clien's totalGB setter (ms epoch and bytes already handled // 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-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
</a-form-item> </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-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
<a-input-number v-model:value="form.limitIp" :min="0" /> <a-input-number v-model:value="form.limitIp" :min="0" />
</a-form-item> </a-form-item>

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { import {
EditOutlined, EditOutlined,
@ -39,6 +39,7 @@ const emit = defineEmits([
'info-client', 'info-client',
'reset-traffic-client', 'reset-traffic-client',
'delete-client', 'delete-client',
'delete-clients',
'toggle-enable-client', 'toggle-enable-client',
]); ]);
@ -162,23 +163,95 @@ function confirmDelete(client) {
function rowKey(client) { function rowKey(client) {
return client.email || client.id || client.password || JSON.stringify(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> </script>
<template> <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 ===================== --> <!-- ====================== Desktop: grid table ===================== -->
<template v-if="!isMobile"> <template v-if="!isMobile">
<div class="client-row client-list-header"> <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-actions">{{ t('pages.settings.actions') }}</div>
<div class="cell cell-enable">{{ t('enable') }}</div> <div class="cell cell-enable">{{ t('enable') }}</div>
<div class="cell cell-online">{{ t('online') }}</div> <div class="cell cell-online">{{ t('online') }}</div>
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</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-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-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div> <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
</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"> <div class="cell cell-actions">
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')"> <a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" /> <QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
@ -262,6 +335,15 @@ function rowKey(client) {
</a-popover> </a-popover>
</div> </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"> <div class="cell cell-alltime">
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag> <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
</div> </div>
@ -301,8 +383,11 @@ function rowKey(client) {
<!-- ====================== Mobile: card list ======================= --> <!-- ====================== Mobile: card list ======================= -->
<template v-else> <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"> <div class="client-card-head">
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
<a-tooltip> <a-tooltip>
<template #title> <template #title>
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template> <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
@ -356,6 +441,15 @@ function rowKey(client) {
<template v-else>{{ totalGbDisplay(client) }}</template> <template v-else>{{ totalGbDisplay(client) }}</template>
</a-tag> </a-tag>
</div> </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"> <div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span> <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag> <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
@ -389,8 +483,28 @@ function rowKey(client) {
font-size: 13px; 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 { .client-row {
display: grid; display: grid;
/* Default no select column (single-client inbounds). The .has-select
* modifier below prepends the 40px checkbox column. */
grid-template-columns: grid-template-columns:
140px 140px
/* actions */ /* actions */
@ -404,6 +518,8 @@ function rowKey(client) {
/* traffic */ /* traffic */
130px 130px
/* all-time */ /* all-time */
130px
/* remained */
140px; 140px;
/* expiry */ /* expiry */
gap: 12px; gap: 12px;
@ -412,6 +528,28 @@ function rowKey(client) {
border-top: 1px solid rgba(128, 128, 128, 0.12); 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 { .client-row:last-child {
border-bottom: 1px solid rgba(128, 128, 128, 0.12); 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 */ /* allow grid children to shrink instead of overflowing */
} }
.cell-select,
.cell-actions, .cell-actions,
.cell-enable, .cell-enable,
.cell-online, .cell-online,
.cell-alltime { .cell-alltime,
.cell-remained {
text-align: center; text-align: center;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View file

@ -387,6 +387,9 @@ const showSubscriptionTab = computed(
<td> <td>
<a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{ <a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
getRemainingStats() }}</a-tag> getRemainingStats() }}</a-tag>
<a-tag v-else-if="!clientSettings.totalGB || clientSettings.totalGB <= 0" color="purple">
<InfinityIcon />
</a-tag>
</td> </td>
<td> <td>
<a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{ <a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{

View file

@ -62,6 +62,7 @@ const emit = defineEmits([
'info-client', 'info-client',
'reset-traffic-client', 'reset-traffic-client',
'delete-client', 'delete-client',
'delete-clients',
'toggle-enable-client', 'toggle-enable-client',
]); ]);
@ -404,6 +405,7 @@ function showQrCodeMenu(dbInbound) {
@info-client="(p) => emit('info-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-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)" /> @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</div> </div>
</div> </div>
@ -423,6 +425,7 @@ function showQrCodeMenu(dbInbound) {
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-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)" /> @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</template> </template>
@ -523,27 +526,35 @@ function showQrCodeMenu(dbInbound) {
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag> <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')"> <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
<template #content> <template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
</div>
</template> </template>
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag> <a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')"> <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
<template #content> <template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</div>
</template> </template>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length <a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
}}</a-tag> }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')"> <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
<template #content> <template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</div>
</template> </template>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length <a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
}}</a-tag> }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')"> <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
<template #content> <template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
</div>
</template> </template>
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag> <a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
</a-popover> </a-popover>

View file

@ -322,6 +322,14 @@ async function onDeleteClient({ dbInbound, client }) {
if (msg?.success) await refresh(); 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 }) { async function onToggleEnableClient({ dbInbound, client, next }) {
// Mirror legacy: clone the parsed inbound, flip enable on the matching // Mirror legacy: clone the parsed inbound, flip enable on the matching
// client, and post the whole client back through updateClient. This // client, and post the whole client back through updateClient. This
@ -593,9 +601,38 @@ function onRowAction({ key, dbInbound }) {
<a-space direction="horizontal"> <a-space direction="horizontal">
<TeamOutlined /> <TeamOutlined />
<a-tag color="green">{{ totals.clients }}</a-tag> <a-tag color="green">{{ totals.clients }}</a-tag>
<a-tag v-if="totals.deactive.length">{{ totals.deactive.length }}</a-tag> <a-popover v-if="totals.deactive.length" :title="t('disabled')">
<a-tag v-if="totals.depleted.length" color="red">{{ totals.depleted.length }}</a-tag> <template #content>
<a-tag v-if="totals.expiring.length" color="orange">{{ totals.expiring.length }}</a-tag> <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> </a-space>
</template> </template>
</CustomStatistic> </CustomStatistic>
@ -613,7 +650,7 @@ function onRowAction({ key, dbInbound }) {
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction" @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient" @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient" @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
@toggle-enable-client="onToggleEnableClient" /> @delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
</a-col> </a-col>
</a-row> </a-row>
</a-spin> </a-spin>
@ -692,3 +729,20 @@ function onRowAction({ key, dbInbound }) {
} }
} }
</style> </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>

View file

@ -287,6 +287,7 @@ export function useInbounds() {
const deactive = []; const deactive = [];
const depleted = []; const depleted = [];
const expiring = []; const expiring = [];
const online = [];
for (const ib of dbInbounds.value) { for (const ib of dbInbounds.value) {
up += ib.up || 0; up += ib.up || 0;
down += ib.down || 0; down += ib.down || 0;
@ -297,9 +298,10 @@ export function useInbounds() {
deactive.push(...c.deactive); deactive.push(...c.deactive);
depleted.push(...c.depleted); depleted.push(...c.depleted);
expiring.push(...c.expiring); 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 // ObjectUtil reference is wired at module load — keeping a no-op import