Procházet zdrojové kódy

feat(inbounds): mobile card layout for inbounds and clients

Replace the cramped <a-table> on <768px with a stacked card list for
both inbounds and the per-client expanded rows. Each card surfaces
protocol, port, node, traffic, all-time traffic, client count and
expiry inline as labeled rows instead of hiding them behind popovers,
fixes the 0px gutter that made cards visually merge, and softens the
in-quota green from #52c41a to #389e0a (Ant green-7) so traffic tags
are no longer blinding on dark themes.
MHSanaei před 12 hodinami
rodič
revize
5ac88271af

+ 263 - 242
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -166,22 +166,20 @@ function rowKey(client) {
 
 <template>
   <div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
-    <!-- ============== Header (desktop only) ============== -->
-    <div v-if="!isMobile" class="client-row client-list-header">
-      <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-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
-      <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
-    </div>
-
-    <!-- ============== Body rows ============== -->
-    <div v-for="client in clients" :key="rowKey(client)" class="client-row">
-      <!-- Desktop: action icon row | Mobile: dropdown menu -->
-      <div class="cell cell-actions">
-        <template v-if="!isMobile">
+    <!-- ====================== Desktop: grid table ===================== -->
+    <template v-if="!isMobile">
+      <div class="client-row client-list-header">
+        <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-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 class="cell cell-actions">
           <a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
             <QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
           </a-tooltip>
@@ -197,181 +195,189 @@ function rowKey(client) {
           <a-tooltip v-if="isRemovable" :title="t('delete')">
             <DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
           </a-tooltip>
-        </template>
-        <a-dropdown v-else :trigger="['click']">
-          <EllipsisOutlined class="row-icon" @click.prevent />
-          <template #overlay>
-            <a-menu>
-              <a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
-                <QrcodeOutlined /> {{ t('qrCode') }}
-              </a-menu-item>
-              <a-menu-item @click="emit('edit-client', { dbInbound, client })">
-                <EditOutlined /> {{ t('edit') }}
-              </a-menu-item>
-              <a-menu-item @click="emit('info-client', { dbInbound, client })">
-                <InfoCircleOutlined /> {{ t('info') }}
-              </a-menu-item>
-              <a-menu-item v-if="client.email" @click="confirmReset(client)">
-                <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
-              </a-menu-item>
-              <a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
-                <DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
-              </a-menu-item>
-            </a-menu>
-          </template>
-        </a-dropdown>
-      </div>
-
-      <!-- Enable switch (hidden on mobile, lives in dropdown) -->
-      <div v-if="!isMobile" class="cell cell-enable">
-        <a-switch :checked="client.enable" size="small"
-          @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
-      </div>
+        </div>
 
-      <!-- Online tag (desktop only) -->
-      <div v-if="!isMobile" class="cell cell-online">
-        <a-popover>
-          <template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
-          <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
-          <a-tag v-else>{{ t('offline') }}</a-tag>
-        </a-popover>
-      </div>
+        <div class="cell cell-enable">
+          <a-switch :checked="client.enable" size="small"
+            @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
+        </div>
 
-      <!-- Client identity: status dot + email + comment -->
-      <div class="cell cell-client">
-        <a-tooltip>
-          <template #title>
-            <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
-            <template v-else-if="!client.enable">{{ t('disabled') }}</template>
-            <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
-            <template v-else>{{ t('offline') }}</template>
-          </template>
-          <a-badge :color="statusBadgeColor(client)" />
-        </a-tooltip>
-        <div class="client-id-stack">
-          <a-tooltip :title="client.email">
-            <span class="client-email">{{ client.email }}</span>
-          </a-tooltip>
-          <span v-if="client.comment && client.comment.trim()" class="client-comment">
-            {{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
-          </span>
+        <div class="cell cell-online">
+          <a-popover>
+            <template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
+            <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
+            <a-tag v-else>{{ t('offline') }}</a-tag>
+          </a-popover>
         </div>
-      </div>
 
-      <!-- Traffic with progress bar (desktop only) -->
-      <div v-if="!isMobile" class="cell cell-traffic">
-        <a-popover>
-          <template v-if="client.email" #content>
-            <table cellpadding="2">
-              <tbody>
-                <tr>
-                  <td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
-                  <td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
-                </tr>
-                <tr v-if="client.totalGB > 0">
-                  <td>{{ t('remained') }}</td>
-                  <td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
-                </tr>
-              </tbody>
-            </table>
-          </template>
-          <div class="usage-bar">
-            <span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
-            <a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
-              :show-info="false" :percent="statsProgress(client.email)" size="small" />
-            <a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)" :show-info="false"
-              :status="isClientDepleted(client.email) ? 'exception' : ''" :percent="statsProgress(client.email)"
-              size="small" />
-            <a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
-            <span class="usage-text">
-              <InfinityIcon v-if="isUnlimitedTotal(client)" />
-              <template v-else>{{ totalGbDisplay(client) }}</template>
+        <div class="cell cell-client">
+          <a-tooltip>
+            <template #title>
+              <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
+              <template v-else-if="!client.enable">{{ t('disabled') }}</template>
+              <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
+              <template v-else>{{ t('offline') }}</template>
+            </template>
+            <a-badge :color="statusBadgeColor(client)" />
+          </a-tooltip>
+          <div class="client-id-stack">
+            <a-tooltip :title="client.email">
+              <span class="client-email">{{ client.email }}</span>
+            </a-tooltip>
+            <span v-if="client.comment && client.comment.trim()" class="client-comment">
+              {{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
             </span>
           </div>
-        </a-popover>
-      </div>
-
-      <!-- All-time traffic (desktop only) -->
-      <div v-if="!isMobile" class="cell cell-alltime">
-        <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
-      </div>
+        </div>
 
-      <!-- Expiry (desktop only) -->
-      <div v-if="!isMobile" class="cell cell-expiry">
-        <template v-if="client.expiryTime !== 0 && client.reset > 0">
+        <div class="cell cell-traffic">
           <a-popover>
-            <template #content>
-              <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
-              <span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
+            <template v-if="client.email" #content>
+              <table cellpadding="2">
+                <tbody>
+                  <tr>
+                    <td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
+                    <td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
+                  </tr>
+                  <tr v-if="client.totalGB > 0">
+                    <td>{{ t('remained') }}</td>
+                    <td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
+                  </tr>
+                </tbody>
+              </table>
             </template>
             <div class="usage-bar">
-              <span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
-              <a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
-                :percent="expireProgress(client.expiryTime, client.reset)" size="small" />
-              <span class="usage-text">{{ client.reset }}d</span>
+              <span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
+              <a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
+                :show-info="false" :percent="statsProgress(client.email)" size="small" />
+              <a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
+                :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
+                :percent="statsProgress(client.email)" size="small" />
+              <a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
+              <span class="usage-text">
+                <InfinityIcon v-if="isUnlimitedTotal(client)" />
+                <template v-else>{{ totalGbDisplay(client) }}</template>
+              </span>
             </div>
           </a-popover>
-        </template>
-        <a-popover v-else-if="client.expiryTime !== 0">
-          <template #content>
-            <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
-            <span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
+        </div>
+
+        <div class="cell cell-alltime">
+          <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
+        </div>
+
+        <div class="cell cell-expiry">
+          <template v-if="client.expiryTime !== 0 && client.reset > 0">
+            <a-popover>
+              <template #content>
+                <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
+                <span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
+              </template>
+              <div class="usage-bar">
+                <span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
+                <a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
+                  :percent="expireProgress(client.expiryTime, client.reset)" size="small" />
+                <span class="usage-text">{{ client.reset }}d</span>
+              </div>
+            </a-popover>
           </template>
-          <a-tag :style="{ minWidth: '50px', border: 'none' }"
-            :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
-            {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
+          <a-popover v-else-if="client.expiryTime !== 0">
+            <template #content>
+              <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
+              <span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
+            </template>
+            <a-tag :style="{ minWidth: '50px', border: 'none' }"
+              :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
+              {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
+            </a-tag>
+          </a-popover>
+          <a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)"
+            :style="{ border: 'none' }" class="infinite-tag">
+            <InfinityIcon />
           </a-tag>
-        </a-popover>
-        <a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
-          class="infinite-tag">
-          <InfinityIcon />
-        </a-tag>
+        </div>
       </div>
+    </template>
+
+    <!-- ====================== Mobile: card list ======================= -->
+    <template v-else>
+      <div v-for="client in clients" :key="rowKey(client)" class="client-card">
+        <div class="client-card-head">
+          <a-tooltip>
+            <template #title>
+              <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
+              <template v-else-if="!client.enable">{{ t('disabled') }}</template>
+              <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
+              <template v-else>{{ t('offline') }}</template>
+            </template>
+            <a-badge :color="statusBadgeColor(client)" />
+          </a-tooltip>
+          <a-tooltip :title="client.email">
+            <span class="client-email">{{ client.email }}</span>
+          </a-tooltip>
+          <div class="client-card-actions">
+            <a-switch :checked="client.enable" size="small"
+              @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
+            <a-dropdown :trigger="['click']" placement="bottomRight">
+              <EllipsisOutlined class="row-icon" @click.prevent />
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
+                    <QrcodeOutlined /> {{ t('qrCode') }}
+                  </a-menu-item>
+                  <a-menu-item @click="emit('edit-client', { dbInbound, client })">
+                    <EditOutlined /> {{ t('edit') }}
+                  </a-menu-item>
+                  <a-menu-item @click="emit('info-client', { dbInbound, client })">
+                    <InfoCircleOutlined /> {{ t('info') }}
+                  </a-menu-item>
+                  <a-menu-item v-if="client.email" @click="confirmReset(client)">
+                    <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
+                  </a-menu-item>
+                  <a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
+                    <DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </div>
+        </div>
 
-      <!-- Mobile-only summary popover (collapses traffic + expiry) -->
-      <div v-if="isMobile" class="cell cell-mobile-info">
-        <a-popover placement="bottomLeft" trigger="click">
-          <template #content>
-            <table cellpadding="2">
-              <tbody>
-                <tr>
-                  <td colspan="2" class="text-center">{{ t('pages.inbounds.traffic') }}</td>
-                </tr>
-                <tr>
-                  <td class="num-cell">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</td>
-                  <td class="num-cell">
-                    <InfinityIcon v-if="isUnlimitedTotal(client)" />
-                    <template v-else>{{ totalGbDisplay(client) }}</template>
-                  </td>
-                </tr>
-                <tr>
-                  <td colspan="2" class="text-center">
-                    <a-divider style="margin: 0" />
-                    {{ t('pages.inbounds.expireDate') }}
-                  </td>
-                </tr>
-                <tr>
-                  <td colspan="2" class="text-center">
-                    <a-tag v-if="client.expiryTime > 0">
-                      {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
-                    </a-tag>
-                    <a-tag v-else-if="client.expiryTime < 0" color="green">
-                      {{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
-                    </a-tag>
-                    <a-tag v-else color="purple">
-                      <InfinityIcon />
-                    </a-tag>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-          </template>
-          <a-button shape="round" size="small">
-            <InfoCircleOutlined />
-          </a-button>
-        </a-popover>
+        <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">
+          <div class="stat-row">
+            <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>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
+            <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
+          </div>
+          <div class="stat-row">
+            <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-else>{{ t('offline') }}</a-tag>
+          </div>
+          <div class="stat-row">
+            <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>
+            <a-tag v-else-if="client.expiryTime < 0" color="green">
+              {{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
+            </a-tag>
+            <a-tag v-else color="purple"><InfinityIcon /></a-tag>
+          </div>
+        </div>
       </div>
-    </div>
+    </template>
   </div>
 </template>
 
@@ -419,12 +425,6 @@ function rowKey(client) {
   letter-spacing: 0.02em;
 }
 
-/* Mobile collapses to a 3-column row: action menu, client info, info popover. */
-.client-list.is-mobile .client-row {
-  grid-template-columns: 36px minmax(0, 1fr) 36px;
-  padding: 8px 12px;
-}
-
 .cell {
   min-width: 0;
   /* allow grid children to shrink instead of overflowing */
@@ -433,8 +433,7 @@ function rowKey(client) {
 .cell-actions,
 .cell-enable,
 .cell-online,
-.cell-alltime,
-.cell-mobile-info {
+.cell-alltime {
   text-align: center;
   display: inline-flex;
   align-items: center;
@@ -540,71 +539,93 @@ function rowKey(client) {
   justify-content: center;
 }
 
-/* Mobile popover content table */
-.text-center {
-  text-align: center;
+/* Strip AD-Vue's default expanded-cell padding so the desktop grid
+ * sits flush against the inbound row's left/right edges. */
+:deep(.ant-table-expanded-row > .ant-table-cell) {
+  padding: 0 !important;
 }
 
-.num-cell {
-  text-align: right;
-  font-size: 12px;
-  padding: 2px 6px;
+/* ===== Mobile card list =========================================== */
+.client-list.is-mobile {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  margin: 0;
 }
 
-/* Strip AD-Vue's default expanded-cell padding so the grid sits
- * flush against the inbound row's left/right edges. */
-:deep(.ant-table-expanded-row > .ant-table-cell) {
-  padding: 0 !important;
+.client-card {
+  border: 1px solid rgba(128, 128, 128, 0.18);
+  border-radius: 8px;
+  padding: 10px 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+:global(body.dark) .client-card {
+  border-color: rgba(255, 255, 255, 0.1);
+}
+
+.client-card-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+.client-card-head .client-email {
+  flex: 1;
+  min-width: 0;
+  font-size: 14px;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.client-card-actions {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+.client-card-actions .row-icon {
+  font-size: 20px;
+  padding: 4px;
+}
+
+.client-comment-line {
+  font-size: 11px;
+  opacity: 0.7;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.client-card-foot {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.client-card-foot .stat-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+.client-card-foot .stat-label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  opacity: 0.6;
+  min-width: 96px;
+  flex-shrink: 0;
+}
+.client-card-foot :deep(.ant-tag) {
+  margin: 0;
 }
 
-/* ===== Mobile polish ===============================================
- * On phones the row collapses to [actions][client][info]. Give those
- * cells room and bump the touch targets so the per-client action
- * dropdown + info popover are easier to hit with a thumb. */
-@media (max-width: 768px) {
-  .client-list.is-mobile .client-row {
-    grid-template-columns: 40px minmax(0, 1fr) 40px;
-    gap: 8px;
-    padding: 10px 10px;
-  }
-
-  .client-list.is-mobile .row-icon {
-    font-size: 20px;
-    padding: 6px;
-  }
-
-  .client-list.is-mobile .cell-mobile-info .ant-btn {
-    width: 32px;
-    height: 32px;
-  }
-
-  /* Make the email more readable; the comment can stay smaller. */
-  .client-list.is-mobile .client-email {
-    font-size: 14px;
-    font-weight: 500;
-  }
-
-  .client-list.is-mobile .client-comment {
-    font-size: 11px;
-  }
-
-  /* Bigger status badge so depleted/online state is visible at a glance. */
-  .client-list.is-mobile .cell-client :deep(.ant-badge-status-dot) {
-    width: 9px;
-    height: 9px;
-  }
-
-  /* Row separators feel cleaner with a slight surface tint per row
-   * — easier to scan than a hairline border on dark backgrounds. */
-  .client-list.is-mobile .client-row:not(.client-list-header) {
-    background: rgba(128, 128, 128, 0.04);
-    border-radius: 8px;
-    margin: 4px 8px;
-    border: none !important;
-  }
-
-  .client-list.is-mobile .client-row:not(.client-list-header):last-child {
-    border: none !important;
-  }
+/* Bigger status badge for thumb-readable state at a glance. */
+.client-card-head :deep(.ant-badge-status-dot) {
+  width: 9px;
+  height: 9px;
 }
 </style>

+ 261 - 80
frontend/src/pages/inbounds/InboundList.vue

@@ -21,6 +21,7 @@ import {
   BlockOutlined,
   DeleteOutlined,
   InfoCircleOutlined,
+  RightOutlined,
 } from '@ant-design/icons-vue';
 
 import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
@@ -140,13 +141,20 @@ const desktopColumns = computed(() => {
   );
   return cols;
 });
-const mobileColumns = computed(() => [
-  { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 10, responsive: ['s'] },
-  { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 25 },
-  { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'left', width: 70 },
-  { title: t('info'), key: 'info', align: 'center', width: 10 },
-]);
-const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
+const columns = computed(() => desktopColumns.value);
+
+// Mobile expansion state — replaces a-table's expandable() since the
+// mobile branch renders a hand-rolled card list rather than a table.
+const expandedIds = ref(new Set());
+function toggleExpanded(id) {
+  const next = new Set(expandedIds.value);
+  if (next.has(id)) next.delete(id);
+  else next.add(id);
+  expandedIds.value = next;
+}
+function isExpanded(id) {
+  return expandedIds.value.has(id);
+}
 
 // ============ Pagination ============================================
 function paginationFor(rows) {
@@ -256,8 +264,155 @@ function showQrCodeMenu(dbInbound) {
         </a-radio-group>
       </div>
 
-      <a-table :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
-        :pagination="paginationFor(visibleInbounds)" :scroll="isMobile ? {} : { x: 1000 }"
+      <!-- ====================== Mobile: card list ======================= -->
+      <div v-if="isMobile" class="inbound-cards">
+        <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
+
+        <div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
+          <!-- Header: chevron (multi-user only) + remark + enable + actions -->
+          <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
+            <RightOutlined v-if="record.isMultiUser()" class="card-expand"
+              :class="{ 'is-expanded': isExpanded(record.id) }" />
+            <span class="card-id">#{{ record.id }}</span>
+            <span class="tag-name">{{ record.remark }}</span>
+            <div class="card-actions" @click.stop>
+              <a-switch :checked="record.enable" size="small"
+                @change="(next) => onSwitchEnable(record, next)" />
+              <a-dropdown :trigger="['click']" placement="bottomRight">
+                <MoreOutlined class="row-action-trigger" @click.prevent />
+                <template #overlay>
+                  <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
+                    <a-menu-item key="edit">
+                      <EditOutlined /> {{ t('edit') }}
+                    </a-menu-item>
+                    <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
+                      <QrcodeOutlined /> {{ t('qrCode') }}
+                    </a-menu-item>
+                    <template v-if="record.isMultiUser()">
+                      <a-menu-item key="addClient">
+                        <UserAddOutlined /> {{ t('pages.client.add') }}
+                      </a-menu-item>
+                      <a-menu-item key="addBulkClient">
+                        <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
+                      </a-menu-item>
+                      <a-menu-item key="copyClients">
+                        <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
+                      </a-menu-item>
+                      <a-menu-item key="resetClients">
+                        <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
+                      </a-menu-item>
+                      <a-menu-item key="export">
+                        <ExportOutlined /> {{ t('pages.inbounds.export') }}
+                      </a-menu-item>
+                      <a-menu-item v-if="subEnable" key="subs">
+                        <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
+                      </a-menu-item>
+                      <a-menu-item key="delDepletedClients" class="danger-item">
+                        <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
+                      </a-menu-item>
+                    </template>
+                    <template v-else>
+                      <a-menu-item key="showInfo">
+                        <InfoCircleOutlined /> {{ t('info') }}
+                      </a-menu-item>
+                    </template>
+                    <a-menu-item key="clipboard">
+                      <CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
+                    </a-menu-item>
+                    <a-menu-item key="resetTraffic">
+                      <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
+                    </a-menu-item>
+                    <a-menu-item key="clone">
+                      <BlockOutlined /> {{ t('pages.inbounds.clone') }}
+                    </a-menu-item>
+                    <a-menu-item key="delete" class="danger-item">
+                      <DeleteOutlined /> {{ t('delete') }}
+                    </a-menu-item>
+                  </a-menu>
+                </template>
+              </a-dropdown>
+            </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">
+                <a-tag color="green">{{ 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="nodesById.size > 0" 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">{{ 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) -->
+          <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
+            <ClientRowTable :db-inbound="record" :is-mobile="true"
+              :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
+              :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
+              @edit-client="(p) => emit('edit-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)"
+              @delete-client="(p) => emit('delete-client', p)"
+              @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
+          </div>
+        </div>
+      </div>
+
+      <!-- ====================== Desktop: a-table ======================== -->
+      <a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
+        :pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }"
         :style="{ marginTop: '10px' }" size="small"
         :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
         <!-- Per-inbound client list, expanded by clicking the row's
@@ -440,49 +595,6 @@ function showQrCodeMenu(dbInbound) {
             </a-tag>
           </template>
 
-          <!-- ============== Mobile info popover ============== -->
-          <template v-else-if="column.key === 'info'">
-            <a-popover placement="bottomRight" trigger="click">
-              <template #content>
-                <table cellpadding="2">
-                  <tbody>
-                    <tr>
-                      <td>{{ t('pages.inbounds.protocol') }}</td>
-                      <td><a-tag color="purple">{{ record.protocol }}</a-tag></td>
-                    </tr>
-                    <tr>
-                      <td>{{ t('pages.inbounds.port') }}</td>
-                      <td><a-tag>{{ record.port }}</a-tag></td>
-                    </tr>
-                    <tr v-if="clientCount[record.id]">
-                      <td>{{ t('clients') }}</td>
-                      <td><a-tag color="blue">{{ clientCount[record.id].clients }}</a-tag></td>
-                    </tr>
-                    <tr>
-                      <td>{{ t('pages.inbounds.traffic') }}</td>
-                      <td>
-                        <a-tag>
-                          {{ SizeFormatter.sizeFormat(record.up + record.down) }} /
-                          <template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
-                          <InfinityIcon v-else />
-                        </a-tag>
-                      </td>
-                    </tr>
-                    <tr>
-                      <td>{{ t('pages.inbounds.expireDate') }}</td>
-                      <td>
-                        <a-tag v-if="record.expiryTime > 0">{{ IntlUtil.formatRelativeTime(record.expiryTime) }}</a-tag>
-                        <a-tag v-else color="purple">
-                          <InfinityIcon />
-                        </a-tag>
-                      </td>
-                    </tr>
-                  </tbody>
-                </table>
-              </template>
-              <InfoCircleOutlined class="row-info-trigger" />
-            </a-popover>
-          </template>
         </template>
       </a-table>
     </a-space>
@@ -510,8 +622,7 @@ function showQrCodeMenu(dbInbound) {
   gap: 4px;
 }
 
-.row-action-trigger,
-.row-info-trigger {
+.row-action-trigger {
   font-size: 20px;
   cursor: pointer;
 }
@@ -566,54 +677,124 @@ function showQrCodeMenu(dbInbound) {
   border-end-end-radius: 8px;
 }
 
-/* ===== Mobile-tightening ============================================
- * Below 768px the inbound list is on a tiny viewport — squeeze the
- * card chrome and table cell padding so the actual rows have room. */
+/* ===== Mobile card list ===========================================
+ * <768px renders inbounds as a vertical stack of cards via the
+ * v-if="isMobile" branch above; the desktop <a-table> isn't mounted
+ * so the legacy table-cell tightening rules went away. */
+.inbound-cards {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  margin-top: 4px;
+}
+
+.inbound-card {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 10px;
+  padding: 12px;
+  background: rgba(255, 255, 255, 0.02);
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+:global(body.dark) .inbound-card {
+  background: rgba(255, 255, 255, 0.03);
+  border-color: rgba(255, 255, 255, 0.1);
+}
+
+.card-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  user-select: none;
+}
+.card-id {
+  font-size: 11px;
+  opacity: 0.6;
+}
+.tag-name {
+  font-weight: 600;
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.card-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+.card-expand {
+  font-size: 12px;
+  opacity: 0.6;
+  transition: transform 150ms ease;
+  flex-shrink: 0;
+}
+.card-expand.is-expanded {
+  transform: rotate(90deg);
+}
+
+.card-stats {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+.stat-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+.stat-label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  opacity: 0.6;
+  min-width: 96px;
+  flex-shrink: 0;
+}
+.card-stats :deep(.ant-tag) {
+  margin: 0;
+}
+
+.card-clients {
+  margin-top: 4px;
+  padding-top: 8px;
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.card-empty {
+  text-align: center;
+  opacity: 0.4;
+  padding: 20px 0;
+}
+
 @media (max-width: 768px) {
-  /* Card header/body breathe less on mobile */
   :deep(.ant-card-head) {
     padding: 0 12px;
     min-height: 44px;
   }
-
   :deep(.ant-card-head-title),
   :deep(.ant-card-extra) {
     padding: 8px 0;
   }
-
   :deep(.ant-card-body) {
     padding: 8px;
   }
 
-  /* Filter bar wraps cleanly without forcing block layout (which made
-   * the input + radio group stack on separate full-width lines). */
   .filter-bar.mobile {
     display: flex;
     flex-wrap: wrap;
     gap: 6px;
   }
-
   .filter-bar.mobile > * {
     margin-bottom: 0;
   }
 
-  /* Tighten table cell padding so the 3 visible columns get room. */
-  :deep(.ant-table-thead > tr > th),
-  :deep(.ant-table-tbody > tr > td) {
-    padding: 8px 6px;
-    font-size: 12px;
-  }
-
-  /* Slightly bigger expand chevron (touch target). */
-  :deep(.ant-table-row-expand-icon) {
-    width: 20px;
-    height: 20px;
-    line-height: 18px;
-  }
-
-  /* The action / info icons are the row's primary touch targets. */
-  .row-action-trigger,
-  .row-info-trigger {
+  .row-action-trigger {
     font-size: 22px;
     padding: 4px;
   }

+ 6 - 6
frontend/src/pages/inbounds/InboundsPage.vue

@@ -549,12 +549,12 @@ function onRowAction({ key, dbInbound }) {
           <a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
             <div v-if="!fetched" class="loading-spacer" />
 
-            <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
+            <a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
               <!-- Summary statistics card -->
               <a-col :span="24">
                 <a-card size="small" hoverable class="summary-card">
                   <a-row :gutter="[16, 12]">
-                    <a-col :sm="12" :md="5">
+                    <a-col :xs="12" :sm="12" :md="5">
                       <CustomStatistic :title="t('pages.inbounds.totalDownUp')"
                         :value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
                         <template #prefix>
@@ -562,7 +562,7 @@ function onRowAction({ key, dbInbound }) {
                         </template>
                       </CustomStatistic>
                     </a-col>
-                    <a-col :sm="12" :md="5">
+                    <a-col :xs="12" :sm="12" :md="5">
                       <CustomStatistic :title="t('pages.inbounds.totalUsage')"
                         :value="SizeFormatter.sizeFormat(totals.up + totals.down)">
                         <template #prefix>
@@ -570,7 +570,7 @@ function onRowAction({ key, dbInbound }) {
                         </template>
                       </CustomStatistic>
                     </a-col>
-                    <a-col :sm="12" :md="5">
+                    <a-col :xs="12" :sm="12" :md="5">
                       <CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
                         :value="SizeFormatter.sizeFormat(totals.allTime)">
                         <template #prefix>
@@ -578,14 +578,14 @@ function onRowAction({ key, dbInbound }) {
                         </template>
                       </CustomStatistic>
                     </a-col>
-                    <a-col :sm="12" :md="5">
+                    <a-col :xs="12" :sm="12" :md="5">
                       <CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
                         <template #prefix>
                           <BarsOutlined />
                         </template>
                       </CustomStatistic>
                     </a-col>
-                    <a-col :sm="24" :md="4">
+                    <a-col :xs="24" :sm="24" :md="4">
                       <CustomStatistic :title="t('clients')" value=" ">
                         <template #prefix>
                           <a-space direction="horizontal">

+ 1 - 1
frontend/src/utils/index.js

@@ -677,7 +677,7 @@ export class CookieManager {
 // "no quota / no expiry / unlimited" sentinel since the AD-Vue green
 // would otherwise read as "healthy / under limit".
 const COLORS = {
-    success: '#52c41a', // AD-Vue success — within quota
+    success: '#389e0a', // AD-Vue green-7 — within quota (toned down from green-6 #52c41a, which was too bright on dark themes)
     warning: '#faad14', // AD-Vue gold — close to quota / about to expire
     danger: '#ff4d4f',  // AD-Vue red — depleted / expired
     purple: '#722ed1',  // AD-Vue purple — unlimited / no expiry