13 次代碼提交 b885a1f8a6 ... 7cd26a0583

作者 SHA1 備註 提交日期
  MHSanaei 7cd26a0583 v3 10 小時之前
  MHSanaei 267fb1c866 refactor(inbounds): reorder Inbound's Data tabs (client first, sub inline) 10 小時之前
  MHSanaei 5ac88271af feat(inbounds): mobile card layout for inbounds and clients 10 小時之前
  MHSanaei b776b33497 fix(ui): correct responsive breakpoints for add client form and bulk 11 小時之前
  MHSanaei 1478124712 fix(ui): correct responsive breakpoints for inbound form and settings 12 小時之前
  MHSanaei 9735d26b3d perf(xray): bound Xray-version request and extend cache 12 小時之前
  MHSanaei 113a29733e feat(logs): mobile-friendly log modals with theme-aware colors 12 小時之前
  MHSanaei 3505430e57 fix(docker): include web/translation in frontend and final stages 13 小時之前
  MHSanaei f68a14a3ca fix(xray): align DNS outbound to spec and repair item-list rules UI 13 小時之前
  MHSanaei 60e2af088d feat(xray): add loopback outbound protocol 13 小時之前
  MHSanaei 917f9b307e fix(xray): surface reverse tags in routing and balancer dropdowns 14 小時之前
  MHSanaei 61c84e8223 fix(panel): make webBasePath work end-to-end in dev and prod 14 小時之前
  MHSanaei 72d8ebd269 fix(x-ui.sh): pass silent flag to stop/start during IP SSL setup 16 小時之前
共有 91 個文件被更改,包括 1681 次插入1064 次删除
  1. 2 7
      Dockerfile
  2. 4 4
      database/db.go
  3. 2 2
      database/model/model.go
  4. 10 1
      frontend/src/api/axios-init.js
  5. 2 2
      frontend/src/components/SettingListItem.vue
  6. 41 20
      frontend/src/models/outbound.js
  7. 1 1
      frontend/src/pages/inbounds/ClientBulkModal.vue
  8. 2 2
      frontend/src/pages/inbounds/ClientFormModal.vue
  9. 263 242
      frontend/src/pages/inbounds/ClientRowTable.vue
  10. 14 14
      frontend/src/pages/inbounds/InboundFormModal.vue
  11. 210 208
      frontend/src/pages/inbounds/InboundInfoModal.vue
  12. 261 80
      frontend/src/pages/inbounds/InboundList.vue
  13. 6 6
      frontend/src/pages/inbounds/InboundsPage.vue
  14. 201 46
      frontend/src/pages/index/LogModal.vue
  15. 193 44
      frontend/src/pages/index/XrayLogModal.vue
  16. 11 5
      frontend/src/pages/xray/BalancersTab.vue
  17. 49 21
      frontend/src/pages/xray/OutboundFormModal.vue
  18. 19 3
      frontend/src/pages/xray/OutboundsTab.vue
  19. 4 0
      frontend/src/pages/xray/RoutingTab.vue
  20. 5 1
      frontend/src/pages/xray/XrayPage.vue
  21. 1 1
      frontend/src/utils/index.js
  22. 125 98
      frontend/vite.config.js
  23. 1 1
      go.mod
  24. 1 1
      logger/logger.go
  25. 9 9
      main.go
  26. 7 7
      sub/sub.go
  27. 4 4
      sub/subClashService.go
  28. 2 2
      sub/subController.go
  29. 6 6
      sub/subJsonService.go
  30. 7 7
      sub/subService.go
  31. 1 1
      util/common/err.go
  32. 3 3
      web/controller/api.go
  33. 4 3
      web/controller/base.go
  34. 4 4
      web/controller/custom_geo.go
  35. 3 3
      web/controller/dist.go
  36. 4 4
      web/controller/inbound.go
  37. 7 5
      web/controller/index.go
  38. 2 2
      web/controller/node.go
  39. 14 14
      web/controller/server.go
  40. 4 4
      web/controller/setting.go
  41. 2 2
      web/controller/util.go
  42. 3 3
      web/controller/websocket.go
  43. 2 2
      web/controller/xray_setting.go
  44. 3 3
      web/controller/xui.go
  45. 1 1
      web/entity/entity.go
  46. 4 4
      web/job/check_client_ip_job.go
  47. 3 3
      web/job/check_client_ip_job_integration_test.go
  48. 1 1
      web/job/check_cpu_usage.go
  49. 1 1
      web/job/check_hash_storage.go
  50. 2 2
      web/job/check_xray_running_job.go
  51. 2 2
      web/job/clear_logs_job.go
  52. 4 4
      web/job/ldap_sync_job.go
  53. 4 4
      web/job/node_heartbeat_job.go
  54. 5 5
      web/job/node_traffic_sync_job.go
  55. 2 2
      web/job/periodic_traffic_reset_job.go
  56. 1 1
      web/job/stats_notify_job.go
  57. 4 4
      web/job/xray_traffic_job.go
  58. 1 1
      web/locale/locale.go
  59. 1 1
      web/middleware/security.go
  60. 1 1
      web/middleware/security_test.go
  61. 2 2
      web/runtime/local.go
  62. 2 2
      web/runtime/manager.go
  63. 2 2
      web/runtime/remote.go
  64. 1 1
      web/runtime/runtime.go
  65. 4 4
      web/service/custom_geo.go
  66. 1 1
      web/service/custom_geo_test.go
  67. 6 6
      web/service/inbound.go
  68. 4 4
      web/service/node.go
  69. 1 1
      web/service/nord.go
  70. 7 7
      web/service/outbound.go
  71. 2 2
      web/service/panel.go
  72. 3 3
      web/service/port_conflict.go
  73. 3 3
      web/service/port_conflict_test.go
  74. 9 7
      web/service/server.go
  75. 8 8
      web/service/setting.go
  76. 8 8
      web/service/tgbot.go
  77. 5 5
      web/service/user.go
  78. 1 1
      web/service/warp.go
  79. 3 3
      web/service/websocket.go
  80. 2 2
      web/service/xray.go
  81. 2 2
      web/service/xray_setting.go
  82. 24 30
      web/session/session.go
  83. 11 11
      web/web.go
  84. 1 1
      web/websocket/hub.go
  85. 2 2
      web/websocket/notifier.go
  86. 3 3
      x-ui.sh
  87. 2 2
      xray/api.go
  88. 1 1
      xray/config.go
  89. 1 1
      xray/inbound.go
  90. 1 1
      xray/log_writer.go
  91. 3 3
      xray/process.go

+ 2 - 7
Dockerfile

@@ -1,19 +1,13 @@
 # ========================================================
 # Stage: Frontend (Vite)
 # ========================================================
-# web/dist/ is .gitignored and embedded into the Go binary via
-# //go:embed all:dist in web/web.go, so the SPA bundle MUST be built
-# before the Go compile step. We build it in its own stage so the
-# Go builder image doesn't need Node installed.
 FROM node:22-alpine AS frontend
 WORKDIR /src/frontend
 COPY frontend/package.json frontend/package-lock.json ./
 RUN npm ci
 COPY frontend/ ./
+COPY web/translation /src/web/translation
 RUN npm run build
-# Vite outDir is set to ../web/dist (see frontend/vite.config.js), so
-# the bundle lands at /src/web/dist — that's what we copy into the
-# next stage.
 
 # ========================================================
 # Stage: Builder
@@ -54,6 +48,7 @@ RUN apk add --no-cache --update \
 COPY --from=builder /app/build/ /app/
 COPY --from=builder /app/DockerEntrypoint.sh /app/
 COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
+COPY --from=builder /app/web/translation /app/web/translation
 
 
 # Configure fail2ban

+ 4 - 4
database/db.go

@@ -12,10 +12,10 @@ import (
 	"path"
 	"slices"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/util/crypto"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"

+ 2 - 2
database/model/model.go

@@ -4,8 +4,8 @@ package model
 import (
 	"fmt"
 
-	"github.com/mhsanaei/3x-ui/v2/util/json_util"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 // Protocol represents the protocol type for Xray inbounds.

+ 10 - 1
frontend/src/api/axios-init.js

@@ -22,7 +22,11 @@ function readMetaToken() {
 // recurse through this same interceptor.
 async function fetchCsrfToken() {
   try {
-    const res = await fetch(CSRF_TOKEN_PATH, {
+    const basePath = window.__X_UI_BASE_PATH__;
+    const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
+      ? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
+      : CSRF_TOKEN_PATH);
+    const res = await fetch(url, {
       method: 'GET',
       credentials: 'same-origin',
       headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -55,6 +59,11 @@ export function setupAxios() {
   axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
   axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
 
+  const basePath = window.__X_UI_BASE_PATH__;
+  if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
+    axios.defaults.baseURL = basePath;
+  }
+
   // Seed the cache from the meta tag if a server-rendered page injected
   // one — saves a round trip on legacy templates that still embed it.
   csrfToken = readMetaToken();

+ 2 - 2
frontend/src/components/SettingListItem.vue

@@ -17,13 +17,13 @@ const padding = computed(() =>
 <template>
   <a-list-item :style="{ padding }">
     <a-row :gutter="[8, 16]">
-      <a-col :lg="24" :xl="12">
+      <a-col :xs="24" :lg="12">
         <a-list-item-meta>
           <template #title><slot name="title" /></template>
           <template #description><slot name="description" /></template>
         </a-list-item-meta>
       </a-col>
-      <a-col :lg="24" :xl="12">
+      <a-col :xs="24" :lg="12">
         <slot name="control" />
       </a-col>
     </a-row>

+ 41 - 20
frontend/src/models/outbound.js

@@ -12,6 +12,7 @@ export const Protocols = {
     Hysteria: "hysteria",
     Socks: "socks",
     HTTP: "http",
+    Loopback: "loopback",
 };
 
 export const SSMethods = {
@@ -1291,7 +1292,6 @@ export class Outbound extends CommonClass {
 
     hasAddressPort() {
         return [
-            Protocols.DNS,
             Protocols.VMess,
             Protocols.VLESS,
             Protocols.Trojan,
@@ -1586,6 +1586,7 @@ Outbound.Settings = class extends CommonClass {
             case Protocols.HTTP: return new Outbound.HttpSettings();
             case Protocols.Wireguard: return new Outbound.WireguardSettings();
             case Protocols.Hysteria: return new Outbound.HysteriaSettings();
+            case Protocols.Loopback: return new Outbound.LoopbackSettings();
             default: return null;
         }
     }
@@ -1603,6 +1604,7 @@ Outbound.Settings = class extends CommonClass {
             case Protocols.HTTP: return Outbound.HttpSettings.fromJson(json);
             case Protocols.Wireguard: return Outbound.WireguardSettings.fromJson(json);
             case Protocols.Hysteria: return Outbound.HysteriaSettings.fromJson(json);
+            case Protocols.Loopback: return Outbound.LoopbackSettings.fromJson(json);
             default: return null;
         }
     }
@@ -1782,6 +1784,23 @@ Outbound.BlackholeSettings = class extends CommonClass {
     }
 };
 
+Outbound.LoopbackSettings = class extends CommonClass {
+    constructor(inboundTag = '') {
+        super();
+        this.inboundTag = inboundTag;
+    }
+
+    static fromJson(json = {}) {
+        return new Outbound.LoopbackSettings(json.inboundTag || '');
+    }
+
+    toJson() {
+        return {
+            inboundTag: this.inboundTag || undefined,
+        };
+    }
+};
+
 Outbound.DNSRule = class extends CommonClass {
     constructor(action = 'direct', qtype = '', domain = '') {
         super();
@@ -1826,15 +1845,17 @@ Outbound.DNSRule = class extends CommonClass {
 
 Outbound.DNSSettings = class extends CommonClass {
     constructor(
-        network = 'udp',
-        address = '',
-        port = 53,
+        rewriteNetwork = '',
+        rewriteAddress = '',
+        rewritePort = 53,
+        userLevel = 0,
         rules = []
     ) {
         super();
-        this.network = network;
-        this.address = address;
-        this.port = port;
+        this.rewriteNetwork = rewriteNetwork;
+        this.rewriteAddress = rewriteAddress;
+        this.rewritePort = rewritePort;
+        this.userLevel = userLevel;
         this.rules = Array.isArray(rules) ? rules.map(rule => rule instanceof Outbound.DNSRule ? rule : Outbound.DNSRule.fromJson(rule)) : [];
     }
 
@@ -1847,25 +1868,25 @@ Outbound.DNSSettings = class extends CommonClass {
     }
 
     static fromJson(json = {}) {
+        // Spec uses rewrite{Network,Address,Port}; older configs used the
+        // bare network/address/port keys — accept both so existing saved
+        // configs keep working after the migration.
         return new Outbound.DNSSettings(
-            json.network,
-            json.address,
-            json.port,
+            json.rewriteNetwork ?? json.network ?? '',
+            json.rewriteAddress ?? json.address ?? '',
+            Number(json.rewritePort ?? json.port ?? 53) || 53,
+            Number(json.userLevel ?? 0) || 0,
             getDNSRulesFromJson(json),
         );
     }
 
     toJson() {
-        const json = {
-            network: this.network,
-            address: this.address,
-            port: this.port,
-        };
-
-        if (this.rules.length > 0) {
-            json.rules = Outbound.DNSRule.toJsonArray(this.rules);
-        }
-
+        const json = {};
+        if (!ObjectUtil.isEmpty(this.rewriteNetwork)) json.rewriteNetwork = this.rewriteNetwork;
+        if (!ObjectUtil.isEmpty(this.rewriteAddress)) json.rewriteAddress = this.rewriteAddress;
+        if (this.rewritePort > 0) json.rewritePort = this.rewritePort;
+        if (this.userLevel > 0) json.userLevel = this.userLevel;
+        if (this.rules.length > 0) json.rules = Outbound.DNSRule.toJsonArray(this.rules);
         return json;
     }
 };

+ 1 - 1
frontend/src/pages/inbounds/ClientBulkModal.vue

@@ -175,7 +175,7 @@ async function submit() {
 <template>
   <a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
     :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
-    <a-form v-if="inbound" :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+    <a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
       <a-form-item :label="t('pages.client.method')">
         <a-select v-model:value="form.emailMethod">
           <a-select-option :value="0">Random</a-select-option>

+ 2 - 2
frontend/src/pages/inbounds/ClientFormModal.vue

@@ -242,8 +242,8 @@ const title = computed(() =>
       {{ t('depleted') }}
     </a-tag>
 
-    <a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ md: { span: 8 } }"
-      :wrapper-col="{ md: { span: 14 } }">
+    <a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
+      :wrapper-col="{ sm: { span: 14 } }">
       <a-form-item :label="t('enable')">
         <a-switch v-model:checked="client.enable" />
       </a-form-item>

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

+ 14 - 14
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -552,7 +552,7 @@ watch(
     <a-tabs v-if="inbound && dbForm" default-active-key="basic">
       <!-- ============================== BASICS ============================== -->
       <a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
-        <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+        <a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
           <a-form-item :label="t('enable')">
             <a-switch v-model:checked="dbForm.enable" />
           </a-form-item>
@@ -621,7 +621,7 @@ watch(
         <template v-if="isMultiUser">
           <a-collapse v-if="mode === 'add' && firstClient" default-active-key="0">
             <a-collapse-panel key="0" header="Client">
-              <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+              <a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
                 <a-form-item label="Enable">
                   <a-switch v-model:checked="firstClient.enable" />
                 </a-form-item>
@@ -729,8 +729,8 @@ watch(
         </template>
 
         <!-- VLess decryption / encryption -->
-        <a-form v-if="protocol === Protocols.VLESS" :colon="false" :label-col="{ md: { span: 8 } }"
-          :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+        <a-form v-if="protocol === Protocols.VLESS" :colon="false" :label-col="{ sm: { span: 8 } }"
+          :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
           <a-form-item label="Decryption">
             <a-input v-model:value="inbound.settings.decryption" />
           </a-form-item>
@@ -751,8 +751,8 @@ watch(
         </a-form>
 
         <!-- Shadowsocks shared fields (method/network/ivCheck) -->
-        <a-form v-if="protocol === Protocols.SHADOWSOCKS" :colon="false" :label-col="{ md: { span: 8 } }"
-          :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+        <a-form v-if="protocol === Protocols.SHADOWSOCKS" :colon="false" :label-col="{ sm: { span: 8 } }"
+          :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
           <a-form-item label="Encryption method">
             <a-select v-model:value="inbound.settings.method" @change="onSSMethodChange">
               <a-select-option v-for="(m, k) in SSMethods" :key="k" :value="m">{{ k }}</a-select-option>
@@ -779,7 +779,7 @@ watch(
 
         <!-- HTTP / Mixed accounts -->
         <a-form v-if="protocol === Protocols.HTTP || protocol === Protocols.MIXED" :colon="false"
-          :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+          :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
           <a-form-item label="Accounts">
             <a-button size="small" @click="protocol === Protocols.HTTP
               ? inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())
@@ -823,8 +823,8 @@ watch(
         </a-form>
 
         <!-- Tunnel -->
-        <a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ md: { span: 8 } }"
-          :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+        <a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
+          :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
           <a-form-item label="Address">
             <a-input v-model:value="inbound.settings.address" />
           </a-form-item>
@@ -844,8 +844,8 @@ watch(
         </a-form>
 
         <!-- WireGuard -->
-        <a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ md: { span: 8 } }"
-          :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+        <a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
+          :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
           <a-form-item>
             <template #label>
               Secret key
@@ -930,7 +930,7 @@ watch(
           </div>
 
           <a-form v-for="(fallback, idx) in inbound.settings.fallbacks" :key="idx" :colon="false"
-            :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+            :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
             <a-divider style="margin: 0">
               Fallback {{ idx + 1 }}
               <DeleteOutlined class="danger-icon" @click="delFallback(idx)" />
@@ -1001,7 +1001,7 @@ watch(
       <!-- ============================== STREAM ============================== -->
       <a-tab-pane v-if="canEnableStream" key="stream"
         tab="Stream"><!-- "Stream" stays literal — it's a wire-format identifier -->
-        <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+        <a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
           <a-form-item v-if="protocol !== Protocols.HYSTERIA" label="Transmission">
             <a-select v-model:value="network" :style="{ width: '75%' }">
               <a-select-option value="tcp">TCP (RAW)</a-select-option>
@@ -1660,7 +1660,7 @@ watch(
 
       <!-- ============================== SNIFFING ============================== -->
       <a-tab-pane key="sniffing" tab="Sniffing"><!-- "Sniffing" stays literal — xray config term -->
-        <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+        <a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
           <a-form-item label="Enabled">
             <a-switch v-model:checked="inbound.sniffing.enabled" />
           </a-form-item>

+ 210 - 208
frontend/src/pages/inbounds/InboundInfoModal.vue

@@ -180,8 +180,7 @@ function downloadText(content, filename) {
   FileManager.downloadTextFile(content, filename);
 }
 
-// Active tab in the 3-pane layout. Reset on each open below.
-const activeTab = ref('inbound');
+const activeTab = ref('client');
 
 // === Build state on open ===========================================
 function genSubLink(subId) {
@@ -195,7 +194,7 @@ watch(() => props.open, (next) => {
   if (!next) return;
   if (!props.dbInbound) return;
 
-  activeTab.value = 'inbound';
+  activeTab.value = props.dbInbound.toInbound().clients?.length ? 'client' : 'inbound';
   dbInbound.value = props.dbInbound;
   inbound.value = props.dbInbound.toInbound();
 
@@ -272,7 +271,214 @@ const showSubscriptionTab = computed(
     <template v-if="dbInbound && inbound">
       <a-tabs v-model:active-key="activeTab">
         <!-- ============================================================
-             TAB 1 — Inbound: protocol, transport, security, per-protocol
+             TAB 1 — Client: per-client info + share links + subscription
+             (subscription is folded in here so users don't need a third
+             tab — the sub URLs are per-client anyway).
+        ============================================================== -->
+        <a-tab-pane v-if="showClientTab" key="client" :tab="t('pages.inbounds.client')">
+          <table class="info-table block">
+            <tbody>
+              <tr>
+                <td>{{ t('pages.inbounds.email') }}</td>
+                <td>
+                  <a-tag v-if="clientSettings.email" color="green">{{ clientSettings.email }}</a-tag>
+                  <a-tag v-else color="red">{{ t('none') }}</a-tag>
+                </td>
+              </tr>
+              <tr v-if="clientSettings.id">
+                <td>ID</td>
+                <td><a-tag>{{ clientSettings.id }}</a-tag></td>
+              </tr>
+              <tr v-if="dbInbound.isVMess">
+                <td>{{ t('security') }}</td>
+                <td><a-tag>{{ clientSettings.security }}</a-tag></td>
+              </tr>
+              <tr v-if="inbound.canEnableTlsFlow()">
+                <td>Flow</td>
+                <td>
+                  <a-tag v-if="clientSettings.flow">{{ clientSettings.flow }}</a-tag>
+                  <a-tag v-else color="orange">{{ t('none') }}</a-tag>
+                </td>
+              </tr>
+              <tr v-if="clientSettings.password">
+                <td>{{ t('password') }}</td>
+                <td><a-tag class="info-large-tag">{{ clientSettings.password }}</a-tag></td>
+              </tr>
+              <tr>
+                <td>{{ t('status') }}</td>
+                <td>
+                  <a-tag v-if="isDepleted" color="red">{{ t('depleted') }}</a-tag>
+                  <a-tag v-else-if="isEnable" color="green">{{ t('enabled') }}</a-tag>
+                  <a-tag v-else>{{ t('disabled') }}</a-tag>
+                </td>
+              </tr>
+              <tr v-if="clientStats">
+                <td>{{ t('usage') }}</td>
+                <td>
+                  <a-tag color="green">
+                    {{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }}
+                  </a-tag>
+                  <a-tag>
+                    ↑ {{ SizeFormatter.sizeFormat(clientStats.up) }} /
+                    {{ SizeFormatter.sizeFormat(clientStats.down) }} ↓
+                  </a-tag>
+                </td>
+              </tr>
+              <tr>
+                <td>{{ t('pages.inbounds.createdAt') }}</td>
+                <td>
+                  <a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker) }}</a-tag>
+                  <a-tag v-else>-</a-tag>
+                </td>
+              </tr>
+              <tr>
+                <td>{{ t('pages.inbounds.updatedAt') }}</td>
+                <td>
+                  <a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker) }}</a-tag>
+                  <a-tag v-else>-</a-tag>
+                </td>
+              </tr>
+              <tr>
+                <td>{{ t('lastOnline') }}</td>
+                <td><a-tag>{{ formatLastOnline(clientSettings.email || '') }}</a-tag></td>
+              </tr>
+              <tr v-if="clientSettings.comment">
+                <td>{{ t('comment') }}</td>
+                <td><a-tag class="info-large-tag">{{ clientSettings.comment }}</a-tag></td>
+              </tr>
+              <tr v-if="ipLimitEnable">
+                <td>{{ t('pages.inbounds.IPLimit') }}</td>
+                <td><a-tag>{{ clientSettings.limitIp }}</a-tag></td>
+              </tr>
+              <tr v-if="ipLimitEnable && clientSettings.limitIp > 0">
+                <td>{{ t('pages.inbounds.IPLimitlog') }}</td>
+                <td>
+                  <div class="ip-log">
+                    <div v-if="clientIpsArray.length > 0">
+                      <a-tag v-for="(item, idx) in clientIpsArray" :key="idx" color="blue" class="ip-log-row">{{ item
+                        }}</a-tag>
+                    </div>
+                    <a-tag v-else>{{ clientIpsText || t('tgbot.noIpRecord') }}</a-tag>
+                  </div>
+                  <div class="ip-log-actions">
+                    <SyncOutlined :spin="refreshing" @click="loadClientIps" />
+                    <a-tooltip :title="t('pages.inbounds.IPLimitlogclear')">
+                      <DeleteOutlined @click="clearClientIps" />
+                    </a-tooltip>
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+
+          <!-- Remaining / total / expiry -->
+          <table class="info-table summary-table">
+            <thead>
+              <tr>
+                <th>{{ t('remained') }}</th>
+                <th>{{ t('pages.inbounds.totalUsage') }}</th>
+                <th>{{ t('pages.inbounds.expireDate') }}</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>
+                  <a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
+                    getRemainingStats() }}</a-tag>
+                </td>
+                <td>
+                  <a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
+                    SizeFormatter.sizeFormat(clientSettings.totalGB) }}</a-tag>
+                  <a-tag v-else color="purple">
+                    <InfinityIcon />
+                  </a-tag>
+                </td>
+                <td>
+                  <a-tag v-if="clientSettings.expiryTime > 0"
+                    :color="ColorUtils.usageColor(Date.now(), expireDiff, clientSettings.expiryTime)">{{
+                      IntlUtil.formatDate(clientSettings.expiryTime, datepicker) }}</a-tag>
+                  <a-tag v-else-if="clientSettings.expiryTime < 0" color="green">
+                    {{ clientSettings.expiryTime / -86400000 }} {{ t('day') }}
+                  </a-tag>
+                  <a-tag v-else color="purple">
+                    <InfinityIcon />
+                  </a-tag>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+
+          <!-- Telegram chat id -->
+          <template v-if="tgBotEnable && clientSettings.tgId">
+            <a-divider>Telegram</a-divider>
+            <div class="tg-row">
+              <a-tag color="blue">{{ clientSettings.tgId }}</a-tag>
+              <a-tooltip :title="t('copy')">
+                <a-button size="small" @click="copyText(clientSettings.tgId)">
+                  <template #icon>
+                    <CopyOutlined />
+                  </template>
+                </a-button>
+              </a-tooltip>
+            </div>
+          </template>
+
+          <!-- Per-client share links (no QR) -->
+          <template v-if="dbInbound.hasLink() && links.length > 0">
+            <a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
+            <div v-for="(link, idx) in links" :key="idx" class="link-panel">
+              <div class="link-panel-header">
+                <a-tag color="green">{{ link.remark || `Link ${idx + 1}` }}</a-tag>
+                <a-tooltip :title="t('copy')">
+                  <a-button size="small" @click="copyText(link.link)">
+                    <template #icon>
+                      <CopyOutlined />
+                    </template>
+                  </a-button>
+                </a-tooltip>
+              </div>
+              <code class="link-panel-text">{{ link.link }}</code>
+            </div>
+          </template>
+
+          <!-- Subscription URLs — folded into the client tab so they sit
+               with the rest of the per-client data. Only visible when
+               subscriptions are enabled and this client has a subId. -->
+          <template v-if="showSubscriptionTab">
+            <a-divider>{{ t('subscription.title') }}</a-divider>
+            <div class="link-panel">
+              <div class="link-panel-header">
+                <a-tag color="green">{{ t('subscription.title') }}</a-tag>
+                <a-tooltip :title="t('copy')">
+                  <a-button size="small" @click="copyText(subLink)">
+                    <template #icon>
+                      <CopyOutlined />
+                    </template>
+                  </a-button>
+                </a-tooltip>
+              </div>
+              <a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
+            </div>
+
+            <div v-if="subSettings.subJsonEnable && subJsonLink" class="link-panel">
+              <div class="link-panel-header">
+                <a-tag color="green">JSON</a-tag>
+                <a-tooltip :title="t('copy')">
+                  <a-button size="small" @click="copyText(subJsonLink)">
+                    <template #icon>
+                      <CopyOutlined />
+                    </template>
+                  </a-button>
+                </a-tooltip>
+              </div>
+              <a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink
+                }}</a>
+            </div>
+          </template>
+        </a-tab-pane>
+
+        <!-- ============================================================
+             TAB 2 — Inbound: protocol, transport, security, per-protocol
         ============================================================== -->
         <a-tab-pane key="inbound" :tab="t('pages.xray.rules.inbound')">
           <dl class="info-list">
@@ -572,210 +778,6 @@ const showSubscriptionTab = computed(
             </div>
           </template>
         </a-tab-pane>
-
-        <!-- ============================================================
-             TAB 2 — Client: per-client info + share links (no QR)
-        ============================================================== -->
-        <a-tab-pane v-if="showClientTab" key="client" :tab="t('pages.inbounds.client')">
-          <table class="info-table block">
-            <tbody>
-              <tr>
-                <td>{{ t('pages.inbounds.email') }}</td>
-                <td>
-                  <a-tag v-if="clientSettings.email" color="green">{{ clientSettings.email }}</a-tag>
-                  <a-tag v-else color="red">{{ t('none') }}</a-tag>
-                </td>
-              </tr>
-              <tr v-if="clientSettings.id">
-                <td>ID</td>
-                <td><a-tag>{{ clientSettings.id }}</a-tag></td>
-              </tr>
-              <tr v-if="dbInbound.isVMess">
-                <td>{{ t('security') }}</td>
-                <td><a-tag>{{ clientSettings.security }}</a-tag></td>
-              </tr>
-              <tr v-if="inbound.canEnableTlsFlow()">
-                <td>Flow</td>
-                <td>
-                  <a-tag v-if="clientSettings.flow">{{ clientSettings.flow }}</a-tag>
-                  <a-tag v-else color="orange">{{ t('none') }}</a-tag>
-                </td>
-              </tr>
-              <tr v-if="clientSettings.password">
-                <td>{{ t('password') }}</td>
-                <td><a-tag class="info-large-tag">{{ clientSettings.password }}</a-tag></td>
-              </tr>
-              <tr>
-                <td>{{ t('status') }}</td>
-                <td>
-                  <a-tag v-if="isDepleted" color="red">{{ t('depleted') }}</a-tag>
-                  <a-tag v-else-if="isEnable" color="green">{{ t('enabled') }}</a-tag>
-                  <a-tag v-else>{{ t('disabled') }}</a-tag>
-                </td>
-              </tr>
-              <tr v-if="clientStats">
-                <td>{{ t('usage') }}</td>
-                <td>
-                  <a-tag color="green">
-                    {{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }}
-                  </a-tag>
-                  <a-tag>
-                    ↑ {{ SizeFormatter.sizeFormat(clientStats.up) }} /
-                    {{ SizeFormatter.sizeFormat(clientStats.down) }} ↓
-                  </a-tag>
-                </td>
-              </tr>
-              <tr>
-                <td>{{ t('pages.inbounds.createdAt') }}</td>
-                <td>
-                  <a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker) }}</a-tag>
-                  <a-tag v-else>-</a-tag>
-                </td>
-              </tr>
-              <tr>
-                <td>{{ t('pages.inbounds.updatedAt') }}</td>
-                <td>
-                  <a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker) }}</a-tag>
-                  <a-tag v-else>-</a-tag>
-                </td>
-              </tr>
-              <tr>
-                <td>{{ t('lastOnline') }}</td>
-                <td><a-tag>{{ formatLastOnline(clientSettings.email || '') }}</a-tag></td>
-              </tr>
-              <tr v-if="clientSettings.comment">
-                <td>{{ t('comment') }}</td>
-                <td><a-tag class="info-large-tag">{{ clientSettings.comment }}</a-tag></td>
-              </tr>
-              <tr v-if="ipLimitEnable">
-                <td>{{ t('pages.inbounds.IPLimit') }}</td>
-                <td><a-tag>{{ clientSettings.limitIp }}</a-tag></td>
-              </tr>
-              <tr v-if="ipLimitEnable && clientSettings.limitIp > 0">
-                <td>{{ t('pages.inbounds.IPLimitlog') }}</td>
-                <td>
-                  <div class="ip-log">
-                    <div v-if="clientIpsArray.length > 0">
-                      <a-tag v-for="(item, idx) in clientIpsArray" :key="idx" color="blue" class="ip-log-row">{{ item
-                        }}</a-tag>
-                    </div>
-                    <a-tag v-else>{{ clientIpsText || t('tgbot.noIpRecord') }}</a-tag>
-                  </div>
-                  <div class="ip-log-actions">
-                    <SyncOutlined :spin="refreshing" @click="loadClientIps" />
-                    <a-tooltip :title="t('pages.inbounds.IPLimitlogclear')">
-                      <DeleteOutlined @click="clearClientIps" />
-                    </a-tooltip>
-                  </div>
-                </td>
-              </tr>
-            </tbody>
-          </table>
-
-          <!-- Remaining / total / expiry -->
-          <table class="info-table summary-table">
-            <thead>
-              <tr>
-                <th>{{ t('remained') }}</th>
-                <th>{{ t('pages.inbounds.totalUsage') }}</th>
-                <th>{{ t('pages.inbounds.expireDate') }}</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <td>
-                  <a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
-                    getRemainingStats() }}</a-tag>
-                </td>
-                <td>
-                  <a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
-                    SizeFormatter.sizeFormat(clientSettings.totalGB) }}</a-tag>
-                  <a-tag v-else color="purple">
-                    <InfinityIcon />
-                  </a-tag>
-                </td>
-                <td>
-                  <a-tag v-if="clientSettings.expiryTime > 0"
-                    :color="ColorUtils.usageColor(Date.now(), expireDiff, clientSettings.expiryTime)">{{
-                      IntlUtil.formatDate(clientSettings.expiryTime, datepicker) }}</a-tag>
-                  <a-tag v-else-if="clientSettings.expiryTime < 0" color="green">
-                    {{ clientSettings.expiryTime / -86400000 }} {{ t('day') }}
-                  </a-tag>
-                  <a-tag v-else color="purple">
-                    <InfinityIcon />
-                  </a-tag>
-                </td>
-              </tr>
-            </tbody>
-          </table>
-
-          <!-- Telegram chat id -->
-          <template v-if="tgBotEnable && clientSettings.tgId">
-            <a-divider>Telegram</a-divider>
-            <div class="tg-row">
-              <a-tag color="blue">{{ clientSettings.tgId }}</a-tag>
-              <a-tooltip :title="t('copy')">
-                <a-button size="small" @click="copyText(clientSettings.tgId)">
-                  <template #icon>
-                    <CopyOutlined />
-                  </template>
-                </a-button>
-              </a-tooltip>
-            </div>
-          </template>
-
-          <!-- Per-client share links (no QR) -->
-          <template v-if="dbInbound.hasLink() && links.length > 0">
-            <a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
-            <div v-for="(link, idx) in links" :key="idx" class="link-panel">
-              <div class="link-panel-header">
-                <a-tag color="green">{{ link.remark || `Link ${idx + 1}` }}</a-tag>
-                <a-tooltip :title="t('copy')">
-                  <a-button size="small" @click="copyText(link.link)">
-                    <template #icon>
-                      <CopyOutlined />
-                    </template>
-                  </a-button>
-                </a-tooltip>
-              </div>
-              <code class="link-panel-text">{{ link.link }}</code>
-            </div>
-          </template>
-        </a-tab-pane>
-
-        <!-- ============================================================
-             TAB 3 — Subscription: clickable subscription URLs
-        ============================================================== -->
-        <a-tab-pane v-if="showSubscriptionTab" key="subscription" :tab="t('subscription.title')">
-          <div class="link-panel">
-            <div class="link-panel-header">
-              <a-tag color="green">{{ t('subscription.title') }}</a-tag>
-              <a-tooltip :title="t('copy')">
-                <a-button size="small" @click="copyText(subLink)">
-                  <template #icon>
-                    <CopyOutlined />
-                  </template>
-                </a-button>
-              </a-tooltip>
-            </div>
-            <a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
-          </div>
-
-          <div v-if="subSettings.subJsonEnable && subJsonLink" class="link-panel">
-            <div class="link-panel-header">
-              <a-tag color="green">JSON</a-tag>
-              <a-tooltip :title="t('copy')">
-                <a-button size="small" @click="copyText(subJsonLink)">
-                  <template #icon>
-                    <CopyOutlined />
-                  </template>
-                </a-button>
-              </a-tooltip>
-            </div>
-            <a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink
-              }}</a>
-          </div>
-        </a-tab-pane>
       </a-tabs>
     </template>
   </a-modal>

+ 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">

+ 201 - 46
frontend/src/pages/index/LogModal.vue

@@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n';
 import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
 
 import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
 
 const { t } = useI18n();
+const { isMobile } = useMediaQuery();
 
 const props = defineProps({
   open: { type: Boolean, default: false },
@@ -20,48 +22,41 @@ const loading = ref(false);
 const logs = ref([]);
 
 const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
-const LEVEL_COLORS = ['#3c89e8', '#008771', '#008771', '#f37b24', '#e04141', '#bcbcbc'];
-
-function escapeHtml(value) {
-  if (value == null) return '';
-  return String(value)
-    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
-}
-
-function formatLogs(lines) {
-  // Each line: "YYYY-MM-DD HH:MM:SS LEVEL - message"
-  // Color the timestamp + level prefix and bold the originating service.
-  let out = '';
-  lines.forEach((log, idx) => {
-    const [data, message] = log.split(' - ', 2);
-    const parts = data.split(' ');
-    if (idx > 0) out += '<br>';
-
-    if (parts.length === 3) {
-      const d = escapeHtml(parts[0]);
-      const t = escapeHtml(parts[1]);
-      const levelRaw = parts[2];
-      const li = LEVELS.indexOf(levelRaw);
-      const levelIndex = li >= 0 ? li : 5;
-      out += `<span style="color: ${LEVEL_COLORS[0]};">${d} ${t}</span> `;
-      out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(levelRaw)}</span>`;
-    } else {
-      const li = LEVELS.indexOf(data);
-      const levelIndex = li >= 0 ? li : 5;
-      out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(data)}</span>`;
-    }
+const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
 
-    if (message) {
-      const prefix = message.startsWith('XRAY:') ? '<b>XRAY: </b>' : '<b>X-UI: </b>';
-      const tail = message.startsWith('XRAY:') ? message.substring(5) : message;
-      out += ' - ' + prefix + escapeHtml(tail);
-    }
-  });
-  return out;
+// Parses "YYYY-MM-DD HH:MM:SS LEVEL - message". Lines without the
+// 3-token header degrade gracefully: the unparsed head becomes the
+// level so it still gets color-coded.
+function parseLogLine(line) {
+  const [head, ...rest] = (line || '').split(' - ');
+  const message = rest.join(' - ');
+  const parts = head.split(' ');
+
+  let date = '';
+  let time = '';
+  let levelText;
+  if (parts.length >= 3) {
+    [date, time, levelText] = parts;
+  } else {
+    levelText = head;
+  }
+
+  const li = LEVELS.indexOf(levelText);
+  const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
+
+  let service = '';
+  let body = message || '';
+  if (body.startsWith('XRAY:')) {
+    service = 'XRAY:';
+    body = body.slice('XRAY:'.length).trimStart();
+  } else if (body) {
+    service = 'X-UI:';
+  }
+
+  return { date, time, levelText, levelClass, service, body };
 }
 
-const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...'));
+const parsedLogs = computed(() => logs.value.map(parseLogLine));
 
 async function refresh() {
   loading.value = true;
@@ -73,8 +68,6 @@ async function refresh() {
     if (msg?.success) {
       logs.value = msg.obj || [];
     }
-    // Keep the spinner visible long enough that rapid filter changes
-    // feel intentional rather than flickery.
     await PromiseUtil.sleep(300);
   } finally {
     loading.value = false;
@@ -89,19 +82,21 @@ function download() {
   FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log');
 }
 
-// Re-fetch whenever the modal opens or any filter changes.
 watch(() => props.open, (next) => { if (next) refresh(); });
 watch([rows, level, syslog], () => { if (props.open) refresh(); });
+
+const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
 </script>
 
 <template>
-  <a-modal :open="open" :closable="true" :footer="null" width="800px" @cancel="close">
+  <a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
+    :class="{ 'logmodal-mobile': isMobile }" @cancel="close">
     <template #title>
       {{ t('pages.index.logs') }}
       <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
     </template>
 
-    <a-form layout="inline">
+    <a-form layout="inline" class="log-toolbar">
       <a-form-item>
         <a-input-group compact>
           <a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
@@ -123,7 +118,7 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
       <a-form-item>
         <a-checkbox v-model:checked="syslog">SysLog</a-checkbox>
       </a-form-item>
-      <a-form-item style="margin-left: auto">
+      <a-form-item class="download-item">
         <a-button type="primary" @click="download">
           <template #icon>
             <DownloadOutlined />
@@ -132,7 +127,43 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
       </a-form-item>
     </a-form>
 
-    <div class="log-container" v-html="formattedLogs" />
+    <div class="log-container" :class="{ 'log-container-mobile': isMobile }">
+      <div v-if="parsedLogs.length === 0" class="log-empty">No Record...</div>
+
+      <template v-else-if="isMobile">
+        <div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
+          <div class="log-card-head">
+            <span v-if="log.date || log.time" class="log-time">
+              <span v-if="log.time">{{ log.time }}</span>
+              <span v-if="log.date" class="log-date">{{ log.date }}</span>
+            </span>
+            <span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
+              {{ log.levelText }}
+            </span>
+          </div>
+          <div v-if="log.body || log.service" class="log-body">
+            <b v-if="log.service">{{ log.service }}</b>
+            <span v-if="log.body" class="log-body-text">{{ log.body }}</span>
+          </div>
+        </div>
+      </template>
+
+      <template v-else>
+        <div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
+          <span v-if="log.date || log.time" class="log-stamp">
+            {{ log.date }}<template v-if="log.date && log.time"> </template>{{ log.time }}
+          </span>
+          <span v-if="log.levelText" class="log-level" :class="log.levelClass">
+            {{ log.levelText }}
+          </span>
+          <template v-if="log.body || log.service">
+            <span> - </span>
+            <b v-if="log.service">{{ log.service }} </b>
+            <span>{{ log.body }}</span>
+          </template>
+        </div>
+      </template>
+    </div>
   </a-modal>
 </template>
 
@@ -143,7 +174,26 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
   margin-left: 10px;
 }
 
+.log-toolbar {
+  flex-wrap: wrap;
+  row-gap: 8px;
+}
+.log-toolbar .download-item {
+  margin-left: auto;
+}
+
 .log-container {
+  /* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
+     below so each level keeps ≥4.5:1 contrast against the container. */
+  --log-stamp:   #3c89e8;
+  --log-debug:   #3c89e8;
+  --log-info:    #008771;
+  --log-notice:  #008771;
+  --log-warning: #f37b24;
+  --log-error:   #e04141;
+  --log-unknown: #595959;
+  --log-divider: rgba(128, 128, 128, 0.18);
+
   margin-top: 12px;
   padding: 10px 12px;
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -158,8 +208,113 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
   background: rgba(0, 0, 0, 0.04);
 }
 
+.log-stamp { color: var(--log-stamp); }
+.log-level { margin-left: 4px; }
+.level-debug   { color: var(--log-debug); }
+.level-info    { color: var(--log-info); }
+.level-notice  { color: var(--log-notice); }
+.level-warning { color: var(--log-warning); }
+.level-error   { color: var(--log-error); }
+.level-unknown { color: var(--log-unknown); }
+
+.log-container-mobile {
+  padding: 8px;
+  white-space: normal;
+  max-height: 70vh;
+}
+
+.log-empty {
+  text-align: center;
+  opacity: 0.5;
+  padding: 20px 0;
+}
+
+.log-line + .log-line {
+  margin-top: 2px;
+}
+
+.log-card {
+  border-bottom: 1px solid var(--log-divider);
+  padding: 8px 0;
+}
+.log-card:last-child { border-bottom: 0; }
+.log-card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+.log-time {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 6px;
+  font-weight: 600;
+  font-size: 12px;
+  letter-spacing: 0.02em;
+}
+.log-date {
+  font-size: 10px;
+  font-weight: 500;
+  opacity: 0.55;
+}
+.log-level-badge {
+  display: inline-block;
+  font-size: 10px;
+  line-height: 14px;
+  padding: 0 6px;
+  border-radius: 4px;
+  border: 1px solid currentColor;
+  letter-spacing: 0.04em;
+  font-weight: 600;
+  white-space: nowrap;
+  background: color-mix(in srgb, currentColor 14%, transparent);
+}
+.log-body {
+  font-size: 12px;
+  word-break: break-word;
+}
+.log-body-text {
+  margin-left: 4px;
+}
+
 :global(body.dark) .log-container {
   background: rgba(255, 255, 255, 0.03);
   border-color: rgba(255, 255, 255, 0.1);
+  color: rgba(255, 255, 255, 0.88);
+
+  --log-stamp:   #6aa6ee;
+  --log-debug:   #6aa6ee;
+  --log-info:    #4ed3a6;
+  --log-notice:  #4ed3a6;
+  --log-warning: #ffb872;
+  --log-error:   #ff7575;
+  --log-unknown: #b5b5b5;
+  --log-divider: rgba(255, 255, 255, 0.1);
+}
+
+:global([data-theme="ultra-dark"]) .log-container {
+  --log-stamp:   #7fb6f1;
+  --log-debug:   #7fb6f1;
+  --log-info:    #5fd9b0;
+  --log-notice:  #5fd9b0;
+  --log-warning: #ffcc88;
+  --log-error:   #ff8a8a;
+  --log-unknown: #c4c4c4;
+  --log-divider: rgba(255, 255, 255, 0.12);
+}
+
+/* Mobile: pull the modal flush with the screen edges. */
+:global(.logmodal-mobile) {
+  top: 0 !important;
+  padding-bottom: 0 !important;
+  max-width: 100vw !important;
+}
+:global(.logmodal-mobile .ant-modal-content) {
+  border-radius: 0;
+  height: 100vh;
+}
+:global(.logmodal-mobile .ant-modal-body) {
+  padding: 12px;
 }
 </style>

+ 193 - 44
frontend/src/pages/index/XrayLogModal.vue

@@ -5,9 +5,11 @@ import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
 
 import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
 import { useDatepicker } from '@/composables/useDatepicker.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
 
 const { t } = useI18n();
 const { datepicker } = useDatepicker();
+const { isMobile } = useMediaQuery();
 
 const props = defineProps({
   open: { type: Boolean, default: false },
@@ -23,42 +25,27 @@ const showProxy = ref(true);
 const loading = ref(false);
 const logs = ref([]);
 
-function escapeHtml(value) {
-  if (value == null) return '';
-  return String(value)
-    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
-}
-
-// Renders a `<table>` with one row per log entry. Event 1 = blocked
-// (red); Event 2 = proxy (blue); Event 0 = direct.
-function formatLogs(lines) {
-  let out = '<table class="xraylog-table"><tr>'
-    + '<th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>'
-    + '</tr>';
-
-  // Reverse a copy — the legacy code mutated state with `.reverse()`.
-  [...lines].reverse().forEach((log) => {
-    let rowStyle = '';
-    if (log.Event === 1) rowStyle = ' style="color: #e04141;"';
-    else if (log.Event === 2) rowStyle = ' style="color: #3c89e8;"';
+// Newest first.
+const orderedLogs = computed(() => [...logs.value].reverse());
 
-    const emailCell = log.Email ? `<td>${escapeHtml(log.Email)}</td>` : '<td></td>';
+const EVENT_LABELS = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
+const EVENT_COLORS = { 0: 'green', 1: 'red', 2: 'blue' };
 
-    out += `<tr${rowStyle}>`
-      + `<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime, datepicker.value))}</b></td>`
-      + `<td>${escapeHtml(log.FromAddress)}</td>`
-      + `<td>${escapeHtml(log.ToAddress)}</td>`
-      + `<td>${escapeHtml(log.Inbound)}</td>`
-      + `<td>${escapeHtml(log.Outbound)}</td>`
-      + emailCell
-      + '</tr>';
-  });
+function eventLabel(ev) { return EVENT_LABELS[ev] || String(ev ?? ''); }
+function eventColor(ev) { return EVENT_COLORS[ev] || 'default'; }
 
-  return out + '</table>';
+function fullDate(value) {
+  return IntlUtil.formatDate(value, datepicker.value);
+}
+function shortTime(value) {
+  if (!value) return '';
+  const d = new Date(value);
+  if (isNaN(d.getTime())) return '';
+  const hh = String(d.getHours()).padStart(2, '0');
+  const mm = String(d.getMinutes()).padStart(2, '0');
+  const ss = String(d.getSeconds()).padStart(2, '0');
+  return `${hh}:${mm}:${ss}`;
 }
-
-const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...'));
 
 async function refresh() {
   loading.value = true;
@@ -85,12 +72,11 @@ function download() {
     FileManager.downloadTextFile('', 'x-ui.log');
     return;
   }
-  const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
   const lines = logs.value.map((l) => {
     try {
       const dt = l.DateTime ? new Date(l.DateTime) : null;
       const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
-      const eventText = eventMap[l.Event] || String(l.Event ?? '');
+      const eventText = eventLabel(l.Event);
       const emailPart = l.Email ? ` Email=${l.Email}` : '';
       return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
     } catch (_e) {
@@ -102,16 +88,19 @@ function download() {
 
 watch(() => props.open, (next) => { if (next) refresh(); });
 watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
+
+const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
 </script>
 
 <template>
-  <a-modal :open="open" :closable="true" :footer="null" width="80vw" @cancel="close">
+  <a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
+    :class="{ 'xraylog-modal-mobile': isMobile }" @cancel="close">
     <template #title>
       {{ t('pages.index.logs') }}
       <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
     </template>
 
-    <a-form layout="inline">
+    <a-form layout="inline" class="log-toolbar">
       <a-form-item>
         <a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
           <a-select-option value="10">10</a-select-option>
@@ -121,7 +110,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
           <a-select-option value="500">500</a-select-option>
         </a-select>
       </a-form-item>
-      <a-form-item :label="t('filter')">
+      <a-form-item :label="t('filter')" class="filter-item">
         <a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
       </a-form-item>
       <a-form-item>
@@ -129,7 +118,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
         <a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
         <a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
       </a-form-item>
-      <a-form-item style="margin-left: auto">
+      <a-form-item class="download-item">
         <a-button type="primary" @click="download">
           <template #icon>
             <DownloadOutlined />
@@ -138,7 +127,55 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
       </a-form-item>
     </a-form>
 
-    <div class="log-container" v-html="formattedLogs" />
+    <div class="log-container" :class="{ 'log-container-mobile': isMobile }">
+      <div v-if="orderedLogs.length === 0" class="log-empty">No Record...</div>
+
+      <template v-else-if="isMobile">
+        <div v-for="(log, idx) in orderedLogs" :key="idx" class="log-card">
+          <div class="log-card-head">
+            <span class="log-time" :title="fullDate(log.DateTime)">{{ shortTime(log.DateTime) }}</span>
+            <a-tag :color="eventColor(log.Event)" class="log-event-tag">{{ eventLabel(log.Event) }}</a-tag>
+          </div>
+          <div class="log-route">
+            <span class="log-addr">{{ log.FromAddress }}</span>
+            <span class="log-arrow">→</span>
+            <span class="log-addr">{{ log.ToAddress }}</span>
+          </div>
+          <div class="log-meta">
+            <span v-if="log.Inbound" class="log-meta-pair">
+              <span class="log-meta-key">in</span>
+              <span class="log-meta-val">{{ log.Inbound }}</span>
+            </span>
+            <span v-if="log.Outbound" class="log-meta-pair">
+              <span class="log-meta-key">out</span>
+              <span class="log-meta-val">{{ log.Outbound }}</span>
+            </span>
+            <span v-if="log.Email" class="log-meta-pair">
+              <span class="log-meta-key">email</span>
+              <span class="log-meta-val">{{ log.Email }}</span>
+            </span>
+          </div>
+        </div>
+      </template>
+
+      <table v-else class="xraylog-table">
+        <thead>
+          <tr>
+            <th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="(log, idx) in orderedLogs" :key="idx" :class="`log-row-${log.Event}`">
+            <td><b>{{ fullDate(log.DateTime) }}</b></td>
+            <td>{{ log.FromAddress }}</td>
+            <td>{{ log.ToAddress }}</td>
+            <td>{{ log.Inbound }}</td>
+            <td>{{ log.Outbound }}</td>
+            <td>{{ log.Email }}</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
   </a-modal>
 </template>
 
@@ -149,7 +186,24 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
   margin-left: 10px;
 }
 
+.log-toolbar {
+  flex-wrap: wrap;
+  row-gap: 8px;
+}
+.log-toolbar .filter-item {
+  flex: 1 1 160px;
+}
+.log-toolbar .download-item {
+  margin-left: auto;
+}
+
 .log-container {
+  /* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
+     below so blocked/proxy rows keep ≥4.5:1 contrast on darker surfaces. */
+  --log-blocked: #e04141;
+  --log-proxy:   #3c89e8;
+  --log-divider: rgba(128, 128, 128, 0.18);
+
   margin-top: 12px;
   padding: 10px 12px;
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -161,22 +215,117 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
   border-radius: 6px;
   background: rgba(0, 0, 0, 0.04);
 }
+.log-container-mobile {
+  padding: 8px;
+  font-size: 12px;
+  max-height: 70vh;
+}
+
+.log-empty {
+  text-align: center;
+  opacity: 0.5;
+  padding: 20px 0;
+}
+
+.log-card {
+  border-bottom: 1px solid var(--log-divider);
+  padding: 8px 0;
+}
+.log-card:last-child { border-bottom: 0; }
+
+.log-card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+.log-time {
+  font-weight: 600;
+  font-size: 12px;
+  letter-spacing: 0.02em;
+}
+.log-event-tag {
+  margin: 0;
+  font-size: 10px;
+  line-height: 16px;
+  padding: 0 6px;
+}
+
+.log-route {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+  font-size: 12px;
+  margin-bottom: 4px;
+}
+.log-addr {
+  word-break: break-all;
+}
+.log-arrow {
+  opacity: 0.5;
+}
+
+.log-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px 12px;
+  font-size: 11px;
+  opacity: 0.75;
+}
+.log-meta-pair {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 4px;
+  word-break: break-all;
+}
+.log-meta-key {
+  font-size: 10px;
+  text-transform: uppercase;
+  opacity: 0.6;
+  letter-spacing: 0.04em;
+}
 
 :global(body.dark) .log-container {
   background: rgba(255, 255, 255, 0.03);
   border-color: rgba(255, 255, 255, 0.1);
+  color: rgba(255, 255, 255, 0.88);
+
+  --log-blocked: #ff7575;
+  --log-proxy:   #6aa6ee;
+  --log-divider: rgba(255, 255, 255, 0.1);
+}
+
+:global([data-theme="ultra-dark"]) .log-container {
+  --log-blocked: #ff8a8a;
+  --log-proxy:   #7fb6f1;
+  --log-divider: rgba(255, 255, 255, 0.12);
+}
+
+/* Mobile: pull the modal flush with the screen edges. */
+:global(.xraylog-modal-mobile) {
+  top: 0 !important;
+  padding-bottom: 0 !important;
+  max-width: 100vw !important;
+}
+:global(.xraylog-modal-mobile .ant-modal-content) {
+  border-radius: 0;
+  height: 100vh;
+}
+:global(.xraylog-modal-mobile .ant-modal-body) {
+  padding: 12px;
 }
-</style>
 
-<style>
-/* Global so the v-html'd table picks up these styles. */
 .xraylog-table {
   border-collapse: collapse;
-  width: auto;
+  width: 100%;
 }
-
 .xraylog-table td,
 .xraylog-table th {
   padding: 2px 15px;
+  text-align: left;
 }
+.xraylog-table .log-row-1 { color: var(--log-blocked); }
+.xraylog-table .log-row-2 { color: var(--log-proxy); }
 </style>

+ 11 - 5
frontend/src/pages/xray/BalancersTab.vue

@@ -20,6 +20,7 @@ const { t } = useI18n();
 
 const props = defineProps({
   templateSettings: { type: Object, default: null },
+  clientReverseTags: { type: Array, default: () => [] },
 });
 
 const STRATEGY_LABELS = {
@@ -40,11 +41,16 @@ const rows = computed(() => {
   }));
 });
 
-const outboundTags = computed(
-  () => (props.templateSettings?.outbounds || [])
-    .filter((o) => o.tag)
-    .map((o) => o.tag),
-);
+const outboundTags = computed(() => {
+  const tags = new Set();
+  for (const o of props.templateSettings?.outbounds || []) {
+    if (o.tag) tags.add(o.tag);
+  }
+  for (const t of props.clientReverseTags || []) {
+    if (t) tags.add(t);
+  }
+  return [...tags];
+});
 
 // === Modal state ====================================================
 const modalOpen = ref(false);

+ 49 - 21
frontend/src/pages/xray/OutboundFormModal.vue

@@ -34,6 +34,7 @@ const props = defineProps({
   open: { type: Boolean, default: false },
   outbound: { type: Object, default: null },
   existingTags: { type: Array, default: () => [] },
+  inboundTags: { type: Array, default: () => [] },
 });
 
 const emit = defineEmits(['update:open', 'confirm']);
@@ -199,6 +200,7 @@ const isBlackhole = computed(() => proto.value === Protocols.Blackhole);
 const isDNS = computed(() => proto.value === Protocols.DNS);
 const isWireguard = computed(() => proto.value === Protocols.Wireguard);
 const isHysteria = computed(() => proto.value === Protocols.Hysteria);
+const isLoopback = computed(() => proto.value === Protocols.Loopback);
 
 function regenerateWgKeys() {
   if (!outbound.value?.settings) return;
@@ -266,21 +268,23 @@ function regenerateWgKeys() {
 
             <a-form-item label="Noises">
               <a-switch :checked="(outbound.settings.noises || []).length > 0"
-                @change="(checked) => outbound.settings.noises = checked ? [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }] : []" />
+                @change="(checked) => outbound.settings.noises = checked ? [new Outbound.FreedomSettings.Noise()] : []" />
               <a-button v-if="outbound.settings.noises && outbound.settings.noises.length > 0" size="small"
                 type="primary" class="ml-8"
-                @click="outbound.settings.noises.push({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })">
+                @click="outbound.settings.noises.push(new Outbound.FreedomSettings.Noise())">
                 <template #icon>
                   <PlusOutlined />
                 </template>
               </a-button>
             </a-form-item>
             <template v-for="(noise, index) in outbound.settings.noises || []" :key="index">
-              <div class="item-heading">
-                <span>Noise {{ index + 1 }}</span>
-                <DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
-                  @click="outbound.settings.noises.splice(index, 1)" />
-              </div>
+              <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }" :colon="false">
+                <div class="item-heading">
+                  <span>Noise {{ index + 1 }}</span>
+                  <DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
+                    @click="outbound.settings.noises.splice(index, 1)" />
+                </div>
+              </a-form-item>
               <a-form-item label="Type">
                 <a-select v-model:value="noise.type">
                   <a-select-option v-for="x in ['rand', 'base64', 'str', 'hex']" :key="x" :value="x">{{ x
@@ -311,26 +315,48 @@ function regenerateWgKeys() {
             </a-form-item>
           </template>
 
+          <!-- ============== Loopback ============== -->
+          <template v-if="isLoopback">
+            <a-form-item label="Inbound tag">
+              <a-auto-complete v-model:value="outbound.settings.inboundTag"
+                :options="inboundTags.map((tag) => ({ value: tag }))"
+                :filter-option="(input, option) => option.value.toLowerCase().includes(input.toLowerCase())"
+                placeholder="tag of an existing inbound to re-route into" />
+            </a-form-item>
+          </template>
+
           <!-- ============== DNS ============== -->
           <template v-if="isDNS">
-            <a-form-item :label="t('pages.inbounds.network')">
-              <a-select v-model:value="outbound.settings.network">
+            <a-form-item label="Rewrite network">
+              <a-select v-model:value="outbound.settings.rewriteNetwork" allow-clear placeholder="(unchanged)">
                 <a-select-option v-for="x in ['udp', 'tcp']" :key="x" :value="x">{{ x }}</a-select-option>
               </a-select>
             </a-form-item>
+            <a-form-item label="Rewrite address">
+              <a-input v-model:value="outbound.settings.rewriteAddress" placeholder="(unchanged) e.g. 1.1.1.1" />
+            </a-form-item>
+            <a-form-item label="Rewrite port">
+              <a-input-number v-model:value="outbound.settings.rewritePort" :min="0" :max="65535"
+                :style="{ width: '100%' }" placeholder="(unchanged)" />
+            </a-form-item>
+            <a-form-item label="User level">
+              <a-input-number v-model:value="outbound.settings.userLevel" :min="0" :style="{ width: '100%' }" />
+            </a-form-item>
             <a-form-item label="Rules">
               <a-button size="small" type="primary"
-                @click="outbound.settings.rules.push({ action: 'direct', qtype: '', domain: '' })">
+                @click="outbound.settings.rules.push(new Outbound.DNSRule())">
                 <template #icon>
                   <PlusOutlined />
                 </template>
               </a-button>
             </a-form-item>
             <template v-for="(rule, index) in outbound.settings.rules || []" :key="index">
-              <div class="item-heading">
-                <span>Rule {{ index + 1 }}</span>
-                <DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
-              </div>
+              <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }" :colon="false">
+                <div class="item-heading">
+                  <span>Rule {{ index + 1 }}</span>
+                  <DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
+                </div>
+              </a-form-item>
               <a-form-item label="Action">
                 <a-select v-model:value="rule.action">
                   <a-select-option v-for="a in DNSRuleActions" :key="a" :value="a">{{ a }}</a-select-option>
@@ -381,18 +407,20 @@ function regenerateWgKeys() {
             </a-form-item>
             <a-form-item label="Peers">
               <a-button size="small" type="primary"
-                @click="outbound.settings.peers.push({ endpoint: '', publicKey: '', psk: '', allowedIPs: [''], keepAlive: 0 })">
+                @click="outbound.settings.peers.push(new Outbound.WireguardSettings.Peer())">
                 <template #icon>
                   <PlusOutlined />
                 </template>
               </a-button>
             </a-form-item>
             <template v-for="(peer, index) in outbound.settings.peers || []" :key="index">
-              <div class="item-heading">
-                <span>Peer {{ index + 1 }}</span>
-                <DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
-                  @click="outbound.settings.peers.splice(index, 1)" />
-              </div>
+              <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }" :colon="false">
+                <div class="item-heading">
+                  <span>Peer {{ index + 1 }}</span>
+                  <DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
+                    @click="outbound.settings.peers.splice(index, 1)" />
+                </div>
+              </a-form-item>
               <a-form-item label="Endpoint">
                 <a-input v-model:value="peer.endpoint" />
               </a-form-item>
@@ -981,9 +1009,9 @@ function regenerateWgKeys() {
 .item-heading {
   display: flex;
   align-items: center;
+  justify-content: space-between;
   gap: 8px;
   font-weight: 500;
-  margin: 8px 0 4px;
   opacity: 0.85;
 }
 

+ 19 - 3
frontend/src/pages/xray/OutboundsTab.vue

@@ -35,9 +35,19 @@ const props = defineProps({
   templateSettings: { type: Object, default: null },
   outboundsTraffic: { type: Array, default: () => [] },
   outboundTestStates: { type: Object, default: () => ({}) },
+  inboundTags: { type: Array, default: () => [] },
   isMobile: { type: Boolean, default: false },
 });
 
+const inboundTagOptions = computed(() => {
+  const out = new Set();
+  for (const ib of props.templateSettings?.inbounds || []) {
+    if (ib.tag) out.add(ib.tag);
+  }
+  for (const t of props.inboundTags || []) out.add(t);
+  return [...out];
+});
+
 const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord']);
 
 // === Modal state ====================================================
@@ -118,8 +128,11 @@ function outboundAddresses(o) {
     case Protocols.Trojan:
       serverObj = o.settings?.servers;
       break;
-    case Protocols.DNS:
-      return [`${o.settings?.address || ''}:${o.settings?.port || ''}`];
+    case Protocols.DNS: {
+      const addr = o.settings?.rewriteAddress || o.settings?.address || '';
+      const port = o.settings?.rewritePort || o.settings?.port || '';
+      return addr || port ? [`${addr}:${port}`] : [];
+    }
     case Protocols.Wireguard:
       return (o.settings?.peers || []).map((p) => p.endpoint);
     default:
@@ -129,7 +142,9 @@ function outboundAddresses(o) {
 }
 
 function isUntestable(o) {
-  return o.protocol === 'blackhole' || o.tag === 'blocked';
+  return o.protocol === Protocols.Blackhole
+    || o.protocol === Protocols.Loopback
+    || o.tag === 'blocked';
 }
 function isTesting(idx) {
   return !!props.outboundTestStates?.[idx]?.testing;
@@ -377,6 +392,7 @@ const rows = computed(() => {
       v-model:open="modalOpen"
       :outbound="editingOutbound"
       :existing-tags="existingTags"
+      :inbound-tags="inboundTagOptions"
       @confirm="onConfirm"
     />
   </a-space>

+ 4 - 0
frontend/src/pages/xray/RoutingTab.vue

@@ -70,6 +70,10 @@ const inboundTagOptions = computed(() => {
     if (ib.tag) out.add(ib.tag);
   }
   for (const t of props.inboundTags || []) out.add(t);
+  for (const ob of props.templateSettings?.outbounds || []) {
+    const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag;
+    if (rt) out.add(rt);
+  }
   // dnsTag if DNS is configured.
   const dt = props.templateSettings?.dns?.tag;
   if (dt) out.add(dt);

+ 5 - 1
frontend/src/pages/xray/XrayPage.vue

@@ -294,6 +294,7 @@ function confirmRestart() {
                         :template-settings="templateSettings"
                         :outbounds-traffic="outboundsTraffic"
                         :outbound-test-states="outboundTestStates"
+                        :inbound-tags="inboundTags"
                         :is-mobile="isMobile"
                         @reset-traffic="resetOutboundsTraffic"
                         @test="onTestOutbound"
@@ -306,7 +307,10 @@ function confirmRestart() {
                       <template #tab>
                         <ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
                       </template>
-                      <BalancersTab :template-settings="templateSettings" />
+                      <BalancersTab
+                        :template-settings="templateSettings"
+                        :client-reverse-tags="clientReverseTags"
+                      />
                     </a-tab-pane>
 
                     <a-tab-pane key="tpl-dns" class="tab-pane">

+ 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

+ 125 - 98
frontend/vite.config.js

@@ -1,89 +1,136 @@
 import { defineConfig } from 'vite';
 import vue from '@vitejs/plugin-vue';
+import fs from 'node:fs';
 import path from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
 
-// Output goes to web/dist/ at the repo root so the Go binary can embed it
-// via embed.FS without reaching outside the web/ tree.
 const outDir = path.resolve(__dirname, '../web/dist');
+const BACKEND_TARGET = 'http://localhost:2053';
 
-// In production the Go binary serves /panel/<route> from web/dist/<route>.html.
-// In dev the Vue app lives at /index.html, /settings.html, ... while AppSidebar
-// links use the production-style /panel/<route> URLs. Map each migrated route
-// to its Vite entry so the sidebar works without relying on the Go backend
-// for already-ported pages.
-const MIGRATED_ROUTES = {
-  '/panel': '/index.html',
-  '/panel/': '/index.html',
-  '/panel/settings': '/settings.html',
-  '/panel/settings/': '/settings.html',
-  '/panel/inbounds': '/inbounds.html',
-  '/panel/inbounds/': '/inbounds.html',
-  '/panel/xray': '/xray.html',
-  '/panel/xray/': '/xray.html',
-  '/panel/nodes': '/nodes.html',
-  '/panel/nodes/': '/nodes.html',
+function resolveDBPath() {
+  const envFolder = process.env.XUI_DB_FOLDER;
+  if (envFolder) return path.join(envFolder, 'x-ui.db');
+  const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
+  if (fs.existsSync(repoDB)) return repoDB;
+  return '/etc/x-ui/x-ui.db';
+}
+
+const BASE_MIGRATED_ROUTES = {
+  'panel': '/index.html',
+  'panel/': '/index.html',
+  'panel/settings': '/settings.html',
+  'panel/settings/': '/settings.html',
+  'panel/inbounds': '/inbounds.html',
+  'panel/inbounds/': '/inbounds.html',
+  'panel/xray': '/xray.html',
+  'panel/xray/': '/xray.html',
+  'panel/nodes': '/nodes.html',
+  'panel/nodes/': '/nodes.html',
 };
 
-// Build a proxy config that suppresses ECONNREFUSED noise when the Go
-// backend isn't running locally. Real errors (timeouts, 5xx, etc.) still
-// surface in the Vite log.
-function makeBackendProxy(target, patterns) {
-  const config = {};
-  for (const pattern of patterns) {
-    config[pattern] = {
-      target,
-      changeOrigin: true,
-      // Returning a path from bypass tells Vite to serve that file from
-      // its own dev server instead of forwarding the request — used here
-      // to short-circuit /panel/<route> for pages we've already migrated.
-      //
-      // Only GETs get bypassed: the xray page reuses its page URL
-      // (`POST /panel/xray/`) for data, so a method-blind bypass would
-      // hand HTML back to fetch calls and break the page in dev.
-      bypass(req) {
-        if (req.method !== 'GET') return undefined;
-        const url = req.url.split('?')[0];
-        if (Object.prototype.hasOwnProperty.call(MIGRATED_ROUTES, url)) {
-          return MIGRATED_ROUTES[url];
-        }
-        return undefined;
-      },
-      configure(proxy) {
-        let warned = false;
-        proxy.on('error', (err, req) => {
-          // Node wraps connection failures in an AggregateError when DNS
-          // returns multiple addresses (e.g. ::1 + 127.0.0.1) and all
-          // refuse — the code lands on the inner errors, not the outer.
-          const codes = new Set();
-          if (err && err.code) codes.add(err.code);
-          if (err && Array.isArray(err.errors)) {
-            for (const inner of err.errors) {
-              if (inner && inner.code) codes.add(inner.code);
-            }
+let cachedBasePath = '/';
+
+function readBasePathFromDB() {
+  const dbPath = resolveDBPath();
+  let db;
+  try {
+    db = new DatabaseSync(dbPath, { readOnly: true });
+  } catch (_e) {
+    return '/';
+  }
+  try {
+    const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('webBasePath');
+    let value = row && typeof row.value === 'string' ? row.value : '/';
+    if (!value.startsWith('/')) value = '/' + value;
+    if (!value.endsWith('/')) value += '/';
+    return value;
+  } catch (_e) {
+    return '/';
+  } finally {
+    db.close();
+  }
+}
+
+function refreshBasePath() {
+  cachedBasePath = readBasePathFromDB();
+  return cachedBasePath;
+}
+
+// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
+// already injects __X_UI_BASE_PATH__ at runtime in production.
+function injectBasePathPlugin() {
+  return {
+    name: 'xui-inject-base-path',
+    apply: 'serve',
+    transformIndexHtml(html) {
+      const basePath = refreshBasePath();
+      const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+      const tag = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
+      return html.replace('</head>', `${tag}</head>`);
+    },
+  };
+}
+
+function bypassMigratedRoute(req) {
+  if (req.method !== 'GET') return undefined;
+  const url = req.url.split('?')[0];
+
+  for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
+    if (url === '/' + key) return value;
+  }
+
+  const m = url.match(/^\/[^/]+\/(.+)$/);
+  if (m) {
+    const stripped = m[1];
+    if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
+  }
+
+  if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
+
+  return undefined;
+}
+
+function rewriteToBackend(p) {
+  if (cachedBasePath === '/' || p.startsWith(cachedBasePath)) return p;
+  return cachedBasePath + p.replace(/^\//, '');
+}
+
+function makeBackendProxy(target) {
+  return {
+    target,
+    changeOrigin: true,
+    rewrite: rewriteToBackend,
+    bypass: bypassMigratedRoute,
+    configure(proxy) {
+      let warned = false;
+      proxy.on('error', (err, req) => {
+        const codes = new Set();
+        if (err && err.code) codes.add(err.code);
+        if (err && Array.isArray(err.errors)) {
+          for (const inner of err.errors) {
+            if (inner && inner.code) codes.add(inner.code);
           }
-          const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
-          if (offline) {
-            // Print a single friendly hint the first time, then stay quiet.
-            if (!warned) {
-              warned = true;
-              // eslint-disable-next-line no-console
-              console.warn(
-                `[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
-              );
-            }
-            return;
+        }
+        const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
+        if (offline) {
+          if (!warned) {
+            warned = true;
+            // eslint-disable-next-line no-console
+            console.warn(
+              `[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
+            );
           }
-          // eslint-disable-next-line no-console
-          console.error('[proxy]', err);
-        });
-      },
-    };
-  }
-  return config;
+          return;
+        }
+        // eslint-disable-next-line no-console
+        console.error('[proxy]', err);
+      });
+    },
+  };
 }
 
 export default defineConfig({
-  plugins: [vue()],
+  plugins: [vue(), injectBasePathPlugin()],
   resolve: {
     alias: {
       '@': path.resolve(__dirname, 'src'),
@@ -94,14 +141,7 @@ export default defineConfig({
     emptyOutDir: true,
     sourcemap: true,
     target: 'es2020',
-    // ant-design-vue is intentionally bundled as one chunk (its
-    // components share internals — splitting it breaks Modal/Form/
-    // Select interop). Minified it lands ~1.4MB but gzips to ~410kB,
-    // so the actual transfer is fine and caches across every page.
-    // Bump the warning past that ceiling so the build stays quiet.
     chunkSizeWarningLimit: 1500,
-    // Multiple HTML entries — one per legacy page we migrate.
-    // As pages get ported in later phases, add their entrypoints here.
     rollupOptions: {
       input: {
         index: path.resolve(__dirname, 'index.html'),
@@ -113,10 +153,6 @@ export default defineConfig({
         subpage: path.resolve(__dirname, 'subpage.html'),
       },
       output: {
-        // Split vendor deps into stable chunks so each page only pulls
-        // what it needs and the browser caches them across versions.
-        // Without this, ant-design-vue + vue + icons all end up in one
-        // 1.6MB blob attached to whichever page consumed them first.
         manualChunks(id) {
           if (!id.includes('node_modules')) return undefined;
           if (id.includes('ant-design-vue')) return 'vendor-antd';
@@ -129,8 +165,6 @@ export default defineConfig({
           if (id.includes('dayjs')) return 'vendor-dayjs';
           if (id.includes('qrious')) return 'vendor-qrious';
           if (id.includes('axios')) return 'vendor-axios';
-          // The persian datepicker pulls in moment + moment-jalaali; bundle
-          // the trio together so unrelated pages don't pay the cost.
           if (
             id.includes('vue3-persian-datetime-picker')
             || id.includes('moment-jalaali')
@@ -146,21 +180,14 @@ export default defineConfig({
     port: 5173,
     strictPort: true,
     proxy: {
-      ...makeBackendProxy('http://localhost:2053', [
-        // Patterns are anchored regex so /login.html and /index.html
-        // (which Vite serves itself) are NOT forwarded — only the bare
-        // backend paths and their sub-routes.
-        '^/(login|logout|getTwoFactorEnable|csrf-token)$',
-        '^/(panel|server)(/|$)',
-      ]),
-      // The panel mounts the live-update WebSocket at /ws (basePath +
-      // "/ws"). Vite needs `ws: true` to forward the HTTP Upgrade to the
-      // Go backend; without it the dev server would 404 the upgrade and
-      // the page falls back to the no-data state.
-      '/ws': {
+      '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
+      '^/$': makeBackendProxy(BACKEND_TARGET),
+      '^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
+      '^/(?:[^/]+/)?ws$': {
         target: 'ws://localhost:2053',
         ws: true,
         changeOrigin: true,
+        rewrite: rewriteToBackend,
       },
     },
   },

+ 1 - 1
go.mod

@@ -1,4 +1,4 @@
-module github.com/mhsanaei/3x-ui/v2
+module github.com/mhsanaei/3x-ui/v3
 
 go 1.26.3
 

+ 1 - 1
logger/logger.go

@@ -9,7 +9,7 @@ import (
 	"runtime"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
+	"github.com/mhsanaei/3x-ui/v3/config"
 	"github.com/op/go-logging"
 )
 

+ 9 - 9
main.go

@@ -11,15 +11,15 @@ import (
 	"syscall"
 	_ "unsafe"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/sub"
-	"github.com/mhsanaei/3x-ui/v2/util/crypto"
-	"github.com/mhsanaei/3x-ui/v2/util/sys"
-	"github.com/mhsanaei/3x-ui/v2/web"
-	"github.com/mhsanaei/3x-ui/v2/web/global"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/sub"
+	"github.com/mhsanaei/3x-ui/v3/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/util/sys"
+	"github.com/mhsanaei/3x-ui/v3/web"
+	"github.com/mhsanaei/3x-ui/v3/web/global"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/joho/godotenv"
 	"github.com/op/go-logging"

+ 7 - 7
sub/sub.go

@@ -13,13 +13,13 @@ import (
 	"strconv"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	webpkg "github.com/mhsanaei/3x-ui/v2/web"
-	"github.com/mhsanaei/3x-ui/v2/web/locale"
-	"github.com/mhsanaei/3x-ui/v2/web/middleware"
-	"github.com/mhsanaei/3x-ui/v2/web/network"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	webpkg "github.com/mhsanaei/3x-ui/v3/web"
+	"github.com/mhsanaei/3x-ui/v3/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/web/network"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
 )

+ 4 - 4
sub/subClashService.go

@@ -7,10 +7,10 @@ import (
 	"github.com/goccy/go-json"
 	yaml "github.com/goccy/go-yaml"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 type SubClashService struct {

+ 2 - 2
sub/subController.go

@@ -9,8 +9,8 @@ import (
 	"strconv"
 	"strings"
 
-	webpkg "github.com/mhsanaei/3x-ui/v2/web"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	webpkg "github.com/mhsanaei/3x-ui/v3/web"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
 )

+ 6 - 6
sub/subJsonService.go

@@ -7,12 +7,12 @@ import (
 	"maps"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/json_util"
-	"github.com/mhsanaei/3x-ui/v2/util/random"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/util/random"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 //go:embed default.json

+ 7 - 7
sub/subService.go

@@ -13,13 +13,13 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/goccy/go-json"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/util/random"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/random"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 // SubService provides business logic for generating subscription links and managing subscription data.

+ 1 - 1
util/common/err.go

@@ -5,7 +5,7 @@ import (
 	"errors"
 	"fmt"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 )
 
 // NewErrorf creates a new error with formatted message.

+ 3 - 3
web/controller/api.go

@@ -4,9 +4,9 @@ import (
 	"net/http"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/web/middleware"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 
 	"github.com/gin-gonic/gin"
 )

+ 4 - 3
web/controller/base.go

@@ -5,9 +5,9 @@ package controller
 import (
 	"net/http"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/locale"
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 
 	"github.com/gin-gonic/gin"
 )
@@ -21,6 +21,7 @@ func (a *BaseController) checkLogin(c *gin.Context) {
 		if isAjax(c) {
 			pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
 		} else {
+			c.Header("Cache-Control", "no-store")
 			c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
 		}
 		c.Abort()

+ 4 - 4
web/controller/custom_geo.go

@@ -5,10 +5,10 @@ import (
 	"net/http"
 	"strconv"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/entity"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
 )

+ 3 - 3
web/controller/dist.go

@@ -10,9 +10,9 @@ import (
 
 	"github.com/gin-gonic/gin"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 )
 
 // distFS is filled in once at startup by the web package via SetDistFS.

+ 4 - 4
web/controller/inbound.go

@@ -6,10 +6,10 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/session"
-	"github.com/mhsanaei/3x-ui/v2/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
 
 	"github.com/gin-gonic/gin"
 )

+ 7 - 5
web/controller/index.go

@@ -5,10 +5,10 @@ import (
 	"text/template"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/middleware"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 
 	"github.com/gin-gonic/gin"
 )
@@ -54,7 +54,8 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
 // index handles the root route, redirecting logged-in users to the panel or showing the login page.
 func (a *IndexController) index(c *gin.Context) {
 	if session.IsLogin(c) {
-		c.Redirect(http.StatusTemporaryRedirect, "panel/")
+		c.Header("Cache-Control", "no-store")
+		c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/")
 		return
 	}
 	serveDistPage(c, "login.html")
@@ -148,6 +149,7 @@ func (a *IndexController) logout(c *gin.Context) {
 	if err := session.ClearSession(c); err != nil {
 		logger.Warning("Unable to clear session on logout:", err)
 	}
+	c.Header("Cache-Control", "no-store")
 	c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
 }
 

+ 2 - 2
web/controller/node.go

@@ -7,8 +7,8 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
 )

+ 14 - 14
web/controller/server.go

@@ -8,11 +8,11 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/entity"
-	"github.com/mhsanaei/3x-ui/v2/web/global"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/web/global"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
 
 	"github.com/gin-gonic/gin"
 )
@@ -143,16 +143,22 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
 	jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
 }
 
-// getXrayVersion retrieves available Xray versions, with caching for 1 minute.
 func (a *ServerController) getXrayVersion(c *gin.Context) {
+	const cacheTTLSeconds = 15 * 60
+
 	now := time.Now().Unix()
-	if now-a.lastGetVersionsTime <= 60 { // 1 minute cache
+	if a.lastVersions != nil && now-a.lastGetVersionsTime <= cacheTTLSeconds {
 		jsonObj(c, a.lastVersions, nil)
 		return
 	}
 
 	versions, err := a.serverService.GetXrayVersions()
 	if err != nil {
+		if a.lastVersions != nil {
+			logger.Warning("getXrayVersion failed; serving cached list:", err)
+			jsonObj(c, a.lastVersions, nil)
+			return
+		}
 		jsonMsg(c, I18nWeb(c, "getVersion"), err)
 		return
 	}
@@ -164,17 +170,11 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
 }
 
 // getPanelUpdateInfo retrieves the current and latest panel version.
-// Network failures (e.g. no internet, GitHub blocked) are logged at debug
-// level only — the panel keeps working offline and we don't want to spam
-// WARN every time a user opens the page.
 func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
 	info, err := a.panelService.GetUpdateInfo()
 	if err != nil {
 		logger.Debug("panel update check failed:", err)
-		c.JSON(http.StatusOK, entity.Msg{
-			Success: false,
-			Msg:     I18nWeb(c, "pages.index.panelUpdateCheckPopover"),
-		})
+		c.JSON(http.StatusOK, entity.Msg{Success: false})
 		return
 	}
 	jsonObj(c, info, nil)

+ 4 - 4
web/controller/setting.go

@@ -4,10 +4,10 @@ import (
 	"errors"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/util/crypto"
-	"github.com/mhsanaei/3x-ui/v2/web/entity"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 
 	"github.com/gin-gonic/gin"
 )

+ 2 - 2
web/controller/util.go

@@ -7,8 +7,8 @@ import (
 	"net/netip"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
 
 	"github.com/gin-gonic/gin"
 )

+ 3 - 3
web/controller/websocket.go

@@ -6,9 +6,9 @@ import (
 	"net/url"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 
 	"github.com/gin-gonic/gin"
 	ws "github.com/gorilla/websocket"

+ 2 - 2
web/controller/xray_setting.go

@@ -3,8 +3,8 @@ package controller
 import (
 	"encoding/json"
 
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
 )

+ 3 - 3
web/controller/xui.go

@@ -3,9 +3,9 @@ package controller
 import (
 	"net/http"
 
-	"github.com/mhsanaei/3x-ui/v2/web/entity"
-	"github.com/mhsanaei/3x-ui/v2/web/middleware"
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 
 	"github.com/gin-gonic/gin"
 )

+ 1 - 1
web/entity/entity.go

@@ -8,7 +8,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
 )
 
 // Msg represents a standard API response message with success status, message text, and optional data object.

+ 4 - 4
web/job/check_client_ip_job.go

@@ -13,10 +13,10 @@ import (
 	"sort"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 // IPWithTimestamp tracks an IP address with its last seen timestamp

+ 3 - 3
web/job/check_client_ip_job_integration_test.go

@@ -9,9 +9,9 @@ import (
 	"testing"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	xuilogger "github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/op/go-logging"
 )
 

+ 1 - 1
web/job/check_cpu_usage.go

@@ -4,7 +4,7 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/shirou/gopsutil/v4/cpu"
 )

+ 1 - 1
web/job/check_hash_storage.go

@@ -1,7 +1,7 @@
 package job
 
 import (
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 )
 
 // CheckHashStorageJob periodically cleans up expired hash entries from the Telegram bot's hash storage.

+ 2 - 2
web/job/check_xray_running_job.go

@@ -3,8 +3,8 @@
 package job
 
 import (
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 )
 
 // CheckXrayRunningJob monitors Xray process health and restarts it if it crashes.

+ 2 - 2
web/job/clear_logs_job.go

@@ -5,8 +5,8 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 // ClearLogsJob clears old log files to prevent disk space issues.

+ 4 - 4
web/job/ldap_sync_job.go

@@ -5,10 +5,10 @@ import (
 
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	ldaputil "github.com/mhsanaei/3x-ui/v2/util/ldap"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"strconv"
 

+ 4 - 4
web/job/node_heartbeat_job.go

@@ -5,10 +5,10 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
 )
 
 // nodeHeartbeatConcurrency caps how many remote panels we probe at once.

+ 5 - 5
web/job/node_traffic_sync_job.go

@@ -5,11 +5,11 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/runtime"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
 )
 
 // nodeTrafficSyncConcurrency caps how many nodes we sync simultaneously.

+ 2 - 2
web/job/periodic_traffic_reset_job.go

@@ -1,8 +1,8 @@
 package job
 
 import (
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 )
 
 // Period represents the time period for traffic resets.

+ 1 - 1
web/job/stats_notify_job.go

@@ -1,7 +1,7 @@
 package job
 
 import (
-	"github.com/mhsanaei/3x-ui/v2/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 )
 
 // LoginStatus represents the status of a login attempt.

+ 4 - 4
web/job/xray_traffic_job.go

@@ -3,10 +3,10 @@ package job
 import (
 	"encoding/json"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/websocket"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 
 	"github.com/valyala/fasthttp"
 )

+ 1 - 1
web/locale/locale.go

@@ -9,7 +9,7 @@ import (
 	"os"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 
 	"github.com/gin-gonic/gin"
 	"github.com/nicksnyder/go-i18n/v2/i18n"

+ 1 - 1
web/middleware/security.go

@@ -3,7 +3,7 @@ package middleware
 import (
 	"net/http"
 
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 
 	"github.com/gin-gonic/gin"
 )

+ 1 - 1
web/middleware/security_test.go

@@ -5,7 +5,7 @@ import (
 	"net/http/httptest"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v2/web/session"
+	"github.com/mhsanaei/3x-ui/v3/web/session"
 
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-contrib/sessions/cookie"

+ 2 - 2
web/runtime/local.go

@@ -6,8 +6,8 @@ import (
 	"errors"
 	"sync"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 // LocalDeps wires the runtime to the panel's xray process and the

+ 2 - 2
web/runtime/manager.go

@@ -4,8 +4,8 @@ import (
 	"errors"
 	"sync"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
 )
 
 // Manager is the entry point for service code that needs a Runtime.

+ 2 - 2
web/runtime/remote.go

@@ -14,8 +14,8 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 )
 
 // remoteHTTPTimeout bounds a single remote API call. Generous enough for

+ 1 - 1
web/runtime/runtime.go

@@ -12,7 +12,7 @@ package runtime
 import (
 	"context"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
 )
 
 // Runtime is the live-engine adapter for one inbound's worth of

+ 4 - 4
web/service/custom_geo.go

@@ -14,10 +14,10 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 )
 
 const (

+ 1 - 1
web/service/custom_geo_test.go

@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
 )
 
 // disableSSRFCheck disables the SSRF guard for the duration of a test,

+ 6 - 6
web/service/inbound.go

@@ -12,12 +12,12 @@ import (
 	"time"
 
 	"github.com/google/uuid"
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/web/runtime"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 
 	"gorm.io/gorm"
 	"gorm.io/gorm/clause"

+ 4 - 4
web/service/node.go

@@ -10,10 +10,10 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
 )
 
 // HeartbeatPatch is the slice of fields a single Probe() result writes

+ 1 - 1
web/service/nord.go

@@ -7,7 +7,7 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
 )
 
 type NordService struct {

+ 7 - 7
web/service/outbound.go

@@ -11,13 +11,13 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/util/json_util"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 
 	"gorm.io/gorm"
 )

+ 2 - 2
web/service/panel.go

@@ -13,8 +13,8 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 )
 
 // PanelService provides business logic for panel management operations.

+ 3 - 3
web/service/port_conflict.go

@@ -5,9 +5,9 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
 )
 
 // transportBits is a bitmask of L4 transports an inbound listens on.

+ 3 - 3
web/service/port_conflict_test.go

@@ -5,9 +5,9 @@ import (
 	"sync"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	xuilogger "github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/op/go-logging"
 )
 

+ 9 - 7
web/service/server.go

@@ -20,12 +20,12 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/util/sys"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/sys"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 
 	"github.com/google/uuid"
 	"github.com/shirou/gopsutil/v4/cpu"
@@ -492,13 +492,15 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
 	return s.emaCPU, nil
 }
 
+var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second}
+
 func (s *ServerService) GetXrayVersions() ([]string, error) {
 	const (
 		XrayURL    = "https://api.github.com/repos/XTLS/Xray-core/releases"
 		bufferSize = 8192
 	)
 
-	resp, err := http.Get(XrayURL)
+	resp, err := xrayVersionsClient.Get(XrayURL)
 	if err != nil {
 		return nil, err
 	}

+ 8 - 8
web/service/setting.go

@@ -12,14 +12,14 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/util/random"
-	"github.com/mhsanaei/3x-ui/v2/util/reflect_util"
-	"github.com/mhsanaei/3x-ui/v2/web/entity"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/random"
+	"github.com/mhsanaei/3x-ui/v3/util/reflect_util"
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 //go:embed config.json

+ 8 - 8
web/service/tgbot.go

@@ -22,14 +22,14 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/web/global"
-	"github.com/mhsanaei/3x-ui/v2/web/locale"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/web/global"
+	"github.com/mhsanaei/3x-ui/v3/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 
 	"github.com/google/uuid"
 	"github.com/mymmrac/telego"

+ 5 - 5
web/service/user.go

@@ -3,11 +3,11 @@ package service
 import (
 	"errors"
 
-	"github.com/mhsanaei/3x-ui/v2/database"
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/crypto"
-	ldaputil "github.com/mhsanaei/3x-ui/v2/util/ldap"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/crypto"
+	ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
 	"github.com/xlzd/gotp"
 	"gorm.io/gorm"
 )

+ 1 - 1
web/service/warp.go

@@ -9,7 +9,7 @@ import (
 	"os"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
 )
 
 // WarpService provides business logic for Cloudflare WARP integration.

+ 3 - 3
web/service/websocket.go

@@ -7,9 +7,9 @@ package service
 import (
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
 
 	"github.com/google/uuid"
 	ws "github.com/gorilla/websocket"

+ 2 - 2
web/service/xray.go

@@ -6,8 +6,8 @@ import (
 	"runtime"
 	"sync"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 
 	"go.uber.org/atomic"
 )

+ 2 - 2
web/service/xray_setting.go

@@ -4,8 +4,8 @@ import (
 	_ "embed"
 	"encoding/json"
 
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/xray"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
 // XraySettingService provides business logic for Xray configuration management.

+ 24 - 30
web/session/session.go

@@ -1,37 +1,27 @@
-// Package session provides session management utilities for the 3x-ui web panel.
-// It handles user authentication state, login sessions, and session storage using Gin sessions.
 package session
 
 import (
 	"encoding/gob"
 	"net/http"
+	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/database/model"
-	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-gonic/gin"
 )
 
 const (
-	loginUserKey = "LOGIN_USER"
-	// apiAuthUserKey is the gin-context key under which checkAPIAuth
-	// stashes a fallback user for Bearer-token-authenticated callers.
-	// Bearer requests don't carry a session cookie, so handlers that
-	// scope writes by user.Id (e.g. InboundController.addInbound) would
-	// otherwise nil-deref. Keeping the override in the gin context
-	// (not the cookie session) means the fallback never leaks into a
-	// browser request.
-	apiAuthUserKey = "api_auth_user"
+	loginUserKey      = "LOGIN_USER"
+	apiAuthUserKey    = "api_auth_user"
+	sessionCookieName = "3x-ui"
 )
 
 func init() {
 	gob.Register(model.User{})
 }
 
-// SetLoginUser stores the authenticated user in the session and persists it.
-// gin-contrib/sessions does not auto-save; callers that forget Save() leave
-// the cookie out of sync with server state — this helper avoids that pitfall.
 func SetLoginUser(c *gin.Context, user *model.User) error {
 	if user == nil {
 		return nil
@@ -41,10 +31,6 @@ func SetLoginUser(c *gin.Context, user *model.User) error {
 	return s.Save()
 }
 
-// SetAPIAuthUser stashes a fallback user on the gin context for the
-// lifetime of a single bearer-authed request. checkAPIAuth calls this
-// after a successful token match so downstream handlers that read
-// GetLoginUser don't see nil.
 func SetAPIAuthUser(c *gin.Context, user *model.User) {
 	if user == nil {
 		return
@@ -52,8 +38,6 @@ func SetAPIAuthUser(c *gin.Context, user *model.User) {
 	c.Set(apiAuthUserKey, user)
 }
 
-// GetLoginUser retrieves the authenticated user from the session.
-// Returns nil if no user is logged in or if the session data is invalid.
 func GetLoginUser(c *gin.Context) *model.User {
 	if v, ok := c.Get(apiAuthUserKey); ok {
 		if u, ok2 := v.(*model.User); ok2 {
@@ -67,8 +51,6 @@ func GetLoginUser(c *gin.Context) *model.User {
 	}
 	user, ok := obj.(model.User)
 	if !ok {
-		// Stale or incompatible session payload — wipe and persist immediately
-		// so subsequent requests don't keep hitting the same broken cookie.
 		s.Delete(loginUserKey)
 		if err := s.Save(); err != nil {
 			logger.Warning("session: failed to drop stale user payload:", err)
@@ -78,14 +60,10 @@ func GetLoginUser(c *gin.Context) *model.User {
 	return &user
 }
 
-// IsLogin checks if a user is currently authenticated in the session.
 func IsLogin(c *gin.Context) bool {
 	return GetLoginUser(c) != nil
 }
 
-// ClearSession invalidates the session and tells the browser to drop the cookie.
-// The cookie attributes (Path/HttpOnly/SameSite) must mirror those used when
-// the cookie was created or browsers will keep it.
 func ClearSession(c *gin.Context) error {
 	s := sessions.Default(c)
 	s.Clear()
@@ -93,12 +71,28 @@ func ClearSession(c *gin.Context) error {
 	if cookiePath == "" {
 		cookiePath = "/"
 	}
+	secure := c.Request.TLS != nil
 	s.Options(sessions.Options{
 		Path:     cookiePath,
 		MaxAge:   -1,
 		HttpOnly: true,
-		Secure:   c.Request.TLS != nil,
+		Secure:   secure,
 		SameSite: http.SameSiteLaxMode,
 	})
-	return s.Save()
+	if err := s.Save(); err != nil {
+		return err
+	}
+	if cookiePath != "/" {
+		http.SetCookie(c.Writer, &http.Cookie{
+			Name:     sessionCookieName,
+			Value:    "",
+			Path:     "/",
+			MaxAge:   -1,
+			Expires:  time.Unix(0, 0),
+			HttpOnly: true,
+			Secure:   secure,
+			SameSite: http.SameSiteLaxMode,
+		})
+	}
+	return nil
 }

+ 11 - 11
web/web.go

@@ -15,17 +15,17 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
-	"github.com/mhsanaei/3x-ui/v2/web/controller"
-	"github.com/mhsanaei/3x-ui/v2/web/job"
-	"github.com/mhsanaei/3x-ui/v2/web/locale"
-	"github.com/mhsanaei/3x-ui/v2/web/middleware"
-	"github.com/mhsanaei/3x-ui/v2/web/network"
-	"github.com/mhsanaei/3x-ui/v2/web/runtime"
-	"github.com/mhsanaei/3x-ui/v2/web/service"
-	"github.com/mhsanaei/3x-ui/v2/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/web/controller"
+	"github.com/mhsanaei/3x-ui/v3/web/job"
+	"github.com/mhsanaei/3x-ui/v3/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/web/network"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
 
 	"github.com/gin-contrib/gzip"
 	"github.com/gin-contrib/sessions"

+ 1 - 1
web/websocket/hub.go

@@ -7,7 +7,7 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 )
 
 // MessageType identifies the kind of WebSocket message.

+ 2 - 2
web/websocket/notifier.go

@@ -2,8 +2,8 @@
 package websocket
 
 import (
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/web/global"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/global"
 )
 
 // GetHub returns the global WebSocket hub instance.

+ 3 - 3
x-ui.sh

@@ -336,16 +336,16 @@ check_config() {
         echo -e "${yellow}You can get a Let's Encrypt certificate for your IP address (valid ~6 days, auto-renews).${plain}"
         read -rp "Generate SSL certificate for IP now? [y/N]: " gen_ssl
         if [[ "$gen_ssl" == "y" || "$gen_ssl" == "Y" ]]; then
-            stop > /dev/null 2>&1
+            stop 0 > /dev/null 2>&1
             ssl_cert_issue_for_ip
             if [[ $? -eq 0 ]]; then
                 echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
                 # ssl_cert_issue_for_ip already restarts the panel, but ensure it's running
-                start > /dev/null 2>&1
+                start 0 > /dev/null 2>&1
             else
                 LOGE "IP certificate setup failed."
                 echo -e "${yellow}You can try again via option 19 (SSL Certificate Management).${plain}"
-                start > /dev/null 2>&1
+                start 0 > /dev/null 2>&1
             fi
         else
             echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"

+ 2 - 2
xray/api.go

@@ -11,8 +11,8 @@ import (
 	"regexp"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
 
 	"github.com/xtls/xray-core/app/proxyman/command"
 	statsService "github.com/xtls/xray-core/app/stats/command"

+ 1 - 1
xray/config.go

@@ -3,7 +3,7 @@ package xray
 import (
 	"bytes"
 
-	"github.com/mhsanaei/3x-ui/v2/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/util/json_util"
 )
 
 // Config represents the complete Xray configuration structure.

+ 1 - 1
xray/inbound.go

@@ -3,7 +3,7 @@ package xray
 import (
 	"bytes"
 
-	"github.com/mhsanaei/3x-ui/v2/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/util/json_util"
 )
 
 // InboundConfig represents an Xray inbound configuration.

+ 1 - 1
xray/log_writer.go

@@ -5,7 +5,7 @@ import (
 	"runtime"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 )
 
 // NewLogWriter returns a new LogWriter for processing Xray log output.

+ 3 - 3
xray/process.go

@@ -14,9 +14,9 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/config"
-	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
 )
 
 // GetBinaryName returns the Xray binary filename for the current OS and architecture.