Bläddra i källkod

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
MHSanaei 1 dag sedan
förälder
incheckning
6d732d8d32

+ 7 - 0
frontend/src/pages/inbounds/ClientBulkModal.vue

@@ -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>

+ 145 - 5
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -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;

+ 3 - 0
frontend/src/pages/inbounds/InboundInfoModal.vue

@@ -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'">{{

+ 15 - 4
frontend/src/pages/inbounds/InboundList.vue

@@ -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>

+ 58 - 4
frontend/src/pages/inbounds/InboundsPage.vue

@@ -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>

+ 3 - 1
frontend/src/pages/inbounds/useInbounds.js

@@ -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