Browse Source

feat(inbounds): collapse mobile cards to id/email + info button

Mobile inbound cards now show only #id and remark; mobile client cards
show only the status badge and email. The full stat grid (protocol,
port, node, traffic, all-time, clients, expiry — and per-client
remained/online/expiry) moves behind a new info icon that opens an
a-modal, so the list stays scannable on small screens.
MHSanaei 1 day ago
parent
commit
933567d423

+ 33 - 19
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -217,6 +217,14 @@ watch(clients, (list) => {
   if (next.size !== selected.value.size) selected.value = next;
   if (next.size !== selected.value.size) selected.value = next;
 });
 });
 
 
+const statsClient = ref(null);
+function openStats(client) {
+  statsClient.value = client;
+}
+function closeStats() {
+  statsClient.value = null;
+}
+
 function confirmBulkDelete() {
 function confirmBulkDelete() {
   const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
   const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
   if (picked.length === 0) return;
   if (picked.length === 0) return;
@@ -433,6 +441,9 @@ function confirmBulkDelete() {
             <span class="client-email">{{ client.email }}</span>
             <span class="client-email">{{ client.email }}</span>
           </a-tooltip>
           </a-tooltip>
           <div class="client-card-actions">
           <div class="client-card-actions">
+            <a-tooltip :title="t('info')">
+              <InfoCircleOutlined class="row-icon" @click="openStats(client)" />
+            </a-tooltip>
             <a-switch :checked="client.enable" size="small"
             <a-switch :checked="client.enable" size="small"
               @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
               @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
             <a-dropdown :trigger="['click']" placement="bottomRight">
             <a-dropdown :trigger="['click']" placement="bottomRight">
@@ -459,52 +470,55 @@ function confirmBulkDelete() {
             </a-dropdown>
             </a-dropdown>
           </div>
           </div>
         </div>
         </div>
+      </div>
 
 
-        <div v-if="client.comment && client.comment.trim()" class="client-comment-line">
-          {{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
-        </div>
-
-        <div class="client-card-foot">
+      <a-modal :open="!!statsClient" :footer="null" :width="360" centered
+        :title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
+        <div v-if="statsClient" class="client-card-foot">
+          <div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
+            {{ statsClient.comment }}
+          </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
             <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
-            <a-tag :color="clientStatsColor(client.email)">
-              {{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
-              <InfinityIcon v-if="isUnlimitedTotal(client)" />
-              <template v-else>{{ totalGbDisplay(client) }}</template>
+            <a-tag :color="clientStatsColor(statsClient.email)">
+              {{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
+              <InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
+              <template v-else>{{ totalGbDisplay(statsClient) }}</template>
             </a-tag>
             </a-tag>
           </div>
           </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('remained') }}</span>
             <span class="stat-label">{{ t('remained') }}</span>
-            <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
+            <a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
               <InfinityIcon />
               <InfinityIcon />
             </a-tag>
             </a-tag>
-            <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
-              {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
+            <a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
+              {{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
             </a-tag>
             </a-tag>
           </div>
           </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(statsClient.email)) }}</a-tag>
           </div>
           </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('online') }}</span>
             <span class="stat-label">{{ t('online') }}</span>
-            <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
+            <a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
             <a-tag v-else>{{ t('offline') }}</a-tag>
             <a-tag v-else>{{ t('offline') }}</a-tag>
           </div>
           </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
             <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
-            <a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
-              {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
+            <a-tag v-if="statsClient.expiryTime > 0"
+              :color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
+              {{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
             </a-tag>
             </a-tag>
-            <a-tag v-else-if="client.expiryTime < 0" color="green">
-              {{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
+            <a-tag v-else-if="statsClient.expiryTime < 0" color="green">
+              {{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
             </a-tag>
             </a-tag>
             <a-tag v-else color="purple">
             <a-tag v-else color="purple">
               <InfinityIcon />
               <InfinityIcon />
             </a-tag>
             </a-tag>
           </div>
           </div>
         </div>
         </div>
-      </div>
+      </a-modal>
     </template>
     </template>
 
 
     <a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
     <a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"

+ 79 - 64
frontend/src/pages/inbounds/InboundList.vue

@@ -263,6 +263,14 @@ function isExpanded(id) {
   return expandedIds.value.has(id);
   return expandedIds.value.has(id);
 }
 }
 
 
+const statsRecord = ref(null);
+function openStats(record) {
+  statsRecord.value = record;
+}
+function closeStats() {
+  statsRecord.value = null;
+}
+
 // ============ Pagination ============================================
 // ============ Pagination ============================================
 function paginationFor(rows) {
 function paginationFor(rows) {
   const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
   const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
@@ -388,13 +396,16 @@ function showQrCodeMenu(dbInbound) {
         <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
         <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
 
 
         <div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
         <div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
-          <!-- Header: chevron (multi-user only) + remark + enable + actions -->
+          <!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
           <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
           <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
             <RightOutlined v-if="record.isMultiUser()" class="card-expand"
             <RightOutlined v-if="record.isMultiUser()" class="card-expand"
               :class="{ 'is-expanded': isExpanded(record.id) }" />
               :class="{ 'is-expanded': isExpanded(record.id) }" />
             <span class="card-id">#{{ record.id }}</span>
             <span class="card-id">#{{ record.id }}</span>
             <span class="tag-name">{{ record.remark }}</span>
             <span class="tag-name">{{ record.remark }}</span>
             <div class="card-actions" @click.stop>
             <div class="card-actions" @click.stop>
+              <a-tooltip :title="t('info')">
+                <InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
+              </a-tooltip>
               <a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
               <a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
               <a-dropdown :trigger="['click']" placement="bottomRight">
               <a-dropdown :trigger="['click']" placement="bottomRight">
                 <MoreOutlined class="row-action-trigger" @click.prevent />
                 <MoreOutlined class="row-action-trigger" @click.prevent />
@@ -452,69 +463,6 @@ function showQrCodeMenu(dbInbound) {
             </div>
             </div>
           </div>
           </div>
 
 
-          <!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
-          <div class="card-stats">
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
-              <a-tag color="purple">{{ record.protocol }}</a-tag>
-              <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
-                <a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
-                <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
-                <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
-              </template>
-            </div>
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.port') }}</span>
-              <a-tag>{{ record.port }}</a-tag>
-            </div>
-            <div v-if="hasActiveNode" class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.node') }}</span>
-              <a-tag v-if="record.nodeId == null" color="default">
-                {{ t('pages.inbounds.localPanel') }}
-              </a-tag>
-              <a-tag v-else-if="nodesById.get(record.nodeId)"
-                :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
-                {{ nodesById.get(record.nodeId).name }}
-              </a-tag>
-              <a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
-            </div>
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
-              <a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
-                {{ SizeFormatter.sizeFormat(record.up + record.down) }} /
-                <template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
-                <InfinityIcon v-else />
-              </a-tag>
-            </div>
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
-              <a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
-            </div>
-            <div v-if="clientCount[record.id]" class="stat-row">
-              <span class="stat-label">{{ t('clients') }}</span>
-              <a-tag color="green" class="client-count-tag">{{ clientCount[record.id].clients }}</a-tag>
-              <a-tag v-if="clientCount[record.id].online.length" color="blue">
-                {{ clientCount[record.id].online.length }} {{ t('online') }}
-              </a-tag>
-              <a-tag v-if="clientCount[record.id].depleted.length" color="red">
-                {{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
-              </a-tag>
-              <a-tag v-if="clientCount[record.id].expiring.length" color="orange">
-                {{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
-              </a-tag>
-            </div>
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
-              <a-tag v-if="record.expiryTime > 0"
-                :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
-                {{ IntlUtil.formatRelativeTime(record.expiryTime) }}
-              </a-tag>
-              <a-tag v-else color="purple">
-                <InfinityIcon />
-              </a-tag>
-            </div>
-          </div>
-
           <!-- Expanded client list (multi-user only) -->
           <!-- Expanded client list (multi-user only) -->
           <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
           <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
             <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
             <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
@@ -530,6 +478,73 @@ function showQrCodeMenu(dbInbound) {
         </div>
         </div>
       </div>
       </div>
 
 
+      <!-- ====================== Mobile: info modal ====================== -->
+      <a-modal v-if="isMobile" :open="!!statsRecord" :footer="null" :width="360" centered
+        :title="statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''" @cancel="closeStats">
+        <div v-if="statsRecord" class="card-stats">
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
+            <a-tag color="purple">{{ statsRecord.protocol }}</a-tag>
+            <template
+              v-if="statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria">
+              <a-tag color="green">{{ statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream.network }}</a-tag>
+              <a-tag v-if="statsRecord.toInbound().stream.isTls" color="blue">TLS</a-tag>
+              <a-tag v-if="statsRecord.toInbound().stream.isReality" color="blue">Reality</a-tag>
+            </template>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.port') }}</span>
+            <a-tag>{{ statsRecord.port }}</a-tag>
+          </div>
+          <div v-if="hasActiveNode" class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.node') }}</span>
+            <a-tag v-if="statsRecord.nodeId == null" color="default">
+              {{ t('pages.inbounds.localPanel') }}
+            </a-tag>
+            <a-tag v-else-if="nodesById.get(statsRecord.nodeId)"
+              :color="nodesById.get(statsRecord.nodeId).status === 'online' ? 'blue' : 'red'">
+              {{ nodesById.get(statsRecord.nodeId).name }}
+            </a-tag>
+            <a-tag v-else color="orange">#{{ statsRecord.nodeId }}</a-tag>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
+            <a-tag :color="ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)">
+              {{ SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down) }} /
+              <template v-if="statsRecord.total > 0">{{ SizeFormatter.sizeFormat(statsRecord.total) }}</template>
+              <InfinityIcon v-else />
+            </a-tag>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
+            <a-tag>{{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }}</a-tag>
+          </div>
+          <div v-if="clientCount[statsRecord.id]" class="stat-row">
+            <span class="stat-label">{{ t('clients') }}</span>
+            <a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
+            <a-tag v-if="clientCount[statsRecord.id].online.length" color="blue">
+              {{ clientCount[statsRecord.id].online.length }} {{ t('online') }}
+            </a-tag>
+            <a-tag v-if="clientCount[statsRecord.id].depleted.length" color="red">
+              {{ clientCount[statsRecord.id].depleted.length }} {{ t('depleted') }}
+            </a-tag>
+            <a-tag v-if="clientCount[statsRecord.id].expiring.length" color="orange">
+              {{ clientCount[statsRecord.id].expiring.length }} {{ t('depletingSoon') }}
+            </a-tag>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
+            <a-tag v-if="statsRecord.expiryTime > 0"
+              :color="ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)">
+              {{ IntlUtil.formatRelativeTime(statsRecord.expiryTime) }}
+            </a-tag>
+            <a-tag v-else color="purple">
+              <InfinityIcon />
+            </a-tag>
+          </div>
+        </div>
+      </a-modal>
+
       <!-- ====================== Desktop: a-table ======================== -->
       <!-- ====================== Desktop: a-table ======================== -->
       <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
       <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
         :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
         :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"