15 次代碼提交 4605f00a15 ... 17e6b5a460

作者 SHA1 備註 提交日期
  Shichao Song 17e6b5a460 inbounds: allow custom monthly traffic reset days (#6071) 6 小時之前
  Intervence 34d2591e50 fix (install.sh): use realpath instead of script name (#6075) 7 小時之前
  PathGao ca6955d88b feat(ui): validate the REALITY client version range at save time (#6126) 7 小時之前
  PathGao 411271b454 refactor(ui): share one onNumber handler for numeric setting inputs (#6127) 7 小時之前
  Tosd e862d81c60 fix(sub): omit hyphen for empty remark variables (#6101) 7 小時之前
  Kim Fom 6af2995930 feat(api): add GET endpoint to look up clients by Telegram ID (#5945) 7 小時之前
  Maksim Alekseev 041476a317 feat(sub): Add XHTTP session field compatibility in share links and subscriptions (#5929) 7 小時之前
  Mr. Nickson ff954ec48c fix: stop deleting client_traffics for detached-but-alive clients (#6110) 8 小時之前
  H-TTTTT 8f49327efb feat(sub): allow identity tokens on every subscription link (#5935) 8 小時之前
  n0liu 8bbca76bdd fix(sub): drop duplicated fingerprint in external-proxy tlsSettings (#6096) 8 小時之前
  PathGao a2774bf212 fix(ui): explain the REALITY client version gate and drop the impossible placeholder (#6125) 8 小時之前
  Jingyue Yao 48675ff197 style(i18n): normalize Chinese-English spacing (#6076) 9 小時之前
  PathGao 604986598f fix(ui): commit date-picker selections immediately instead of on confirm (#6122) 9 小時之前
  Sanaei b6473004ac fix(ci): harden the conflict resolver against the branch it checks out 10 小時之前
  PathGao 579acbc669 fix(settings): keep the stored port when a port field is cleared (#6121) 10 小時之前
共有 83 個文件被更改,包括 1415 次插入198 次删除
  1. 9 20
      .github/workflows/claude-bot.yml
  2. 2 2
      docs/architecture.md
  3. 3 0
      docs/content/docs/en/config/inbounds.mdx
  4. 7 0
      docs/content/docs/en/config/reality.mdx
  5. 3 0
      docs/content/docs/fa/config/inbounds.mdx
  6. 7 0
      docs/content/docs/fa/config/reality.mdx
  7. 3 0
      docs/content/docs/ru/config/inbounds.mdx
  8. 8 0
      docs/content/docs/ru/config/reality.mdx
  9. 3 0
      docs/content/docs/zh/config/inbounds.mdx
  10. 1 0
      docs/content/docs/zh/config/reality.mdx
  11. 8 0
      docs/public/openapi.json
  12. 58 0
      frontend/public/openapi.json
  13. 2 0
      frontend/src/components/form/DateTimePicker.tsx
  14. 3 0
      frontend/src/generated/examples.ts
  15. 16 0
      frontend/src/generated/schemas.ts
  16. 3 0
      frontend/src/generated/types.ts
  17. 3 0
      frontend/src/generated/zod.ts
  18. 17 10
      frontend/src/hooks/useClients.ts
  19. 4 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  20. 10 2
      frontend/src/lib/xray/inbound-link.ts
  21. 57 0
      frontend/src/lib/xray/stream-wire-normalize.ts
  22. 3 0
      frontend/src/models/dbinbound.ts
  23. 1 0
      frontend/src/models/setting.ts
  24. 10 0
      frontend/src/pages/api-docs/endpoints.ts
  25. 1 4
      frontend/src/pages/clients/ClientsPage.tsx
  26. 12 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  27. 31 2
      frontend/src/pages/inbounds/form/security/reality.tsx
  28. 2 1
      frontend/src/pages/settings/EmailTab.tsx
  29. 10 9
      frontend/src/pages/settings/GeneralTab.tsx
  30. 3 2
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  31. 13 2
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  32. 2 1
      frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx
  33. 2 1
      frontend/src/pages/xray/dns/DnsTab.tsx
  34. 3 1
      frontend/src/pages/xray/dns/useDnsColumns.tsx
  35. 3 2
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  36. 1 0
      frontend/src/schemas/forms/inbound-form.ts
  37. 1 0
      frontend/src/schemas/setting.ts
  38. 26 2
      frontend/src/test/clients-summary.test.ts
  39. 43 0
      frontend/src/test/date-time-picker.test.tsx
  40. 40 0
      frontend/src/test/general-tab.test.tsx
  41. 7 0
      frontend/src/test/inbound-form-adapter.test.ts
  42. 70 0
      frontend/src/test/inbound-link.test.ts
  43. 23 0
      frontend/src/test/on-number.test.ts
  44. 22 0
      frontend/src/test/setting-sub-identity.test.ts
  45. 60 0
      frontend/src/test/stream-wire-normalize.test.ts
  46. 31 0
      frontend/src/test/subscription-general-tab.test.tsx
  47. 16 0
      frontend/src/utils/onNumber.ts
  48. 1 3
      install.sh
  49. 14 0
      internal/database/db.go
  50. 2 1
      internal/database/model/model.go
  51. 79 22
      internal/sub/remark_vars.go
  52. 72 0
      internal/sub/remark_vars_test.go
  53. 17 9
      internal/sub/service.go
  54. 50 0
      internal/sub/service_test.go
  55. 44 11
      internal/web/controller/client.go
  56. 6 5
      internal/web/entity/entity.go
  57. 24 3
      internal/web/job/periodic_traffic_reset_job.go
  58. 31 0
      internal/web/job/periodic_traffic_reset_job_test.go
  59. 3 0
      internal/web/runtime/remote.go
  60. 6 3
      internal/web/runtime/remote_test.go
  61. 10 0
      internal/web/service/client_lookup.go
  62. 80 0
      internal/web/service/client_lookup_test.go
  63. 10 0
      internal/web/service/inbound.go
  64. 9 2
      internal/web/service/inbound_migration.go
  65. 59 0
      internal/web/service/inbound_migration_test.go
  66. 5 1
      internal/web/service/inbound_node.go
  67. 18 0
      internal/web/service/inbound_traffic_reset_test.go
  68. 5 0
      internal/web/service/setting.go
  69. 39 0
      internal/web/service/setting_sub_identity_test.go
  70. 7 0
      internal/web/translation/ar-EG.json
  71. 7 0
      internal/web/translation/en-US.json
  72. 7 0
      internal/web/translation/es-ES.json
  73. 7 0
      internal/web/translation/fa-IR.json
  74. 7 0
      internal/web/translation/id-ID.json
  75. 7 0
      internal/web/translation/ja-JP.json
  76. 7 0
      internal/web/translation/pt-BR.json
  77. 7 0
      internal/web/translation/ru-RU.json
  78. 7 0
      internal/web/translation/tr-TR.json
  79. 7 0
      internal/web/translation/uk-UA.json
  80. 7 0
      internal/web/translation/vi-VN.json
  81. 42 35
      internal/web/translation/zh-CN.json
  82. 42 35
      internal/web/translation/zh-TW.json
  83. 7 7
      internal/web/web.go

+ 9 - 20
.github/workflows/claude-bot.yml

@@ -1,24 +1,5 @@
 name: Claude Bot
 
-# Every prompt: / claude_args: block below interpolates ${{ }}, so GitHub parses
-# the whole block scalar as ONE expression and caps it at 21000 characters.
-# Going over does not fail a job - the entire workflow stops parsing and
-# vanishes from Actions, with the run reported only as a workflow file issue.
-# The two triage prompts are the ones to watch: roughly 15100 characters each.
-# Put shared context in CLAUDE.md and docs/architecture.md, which are in the
-# checkout, instead of pasting it here.
-#
-# CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0 on the two jobs that set
-# allowed_non_write_users: the action otherwise turns subprocess isolation on
-# for them, installs bubblewrap, and every Bash call then dies in the sandbox
-# with "bwrap: Can't create file at /home/.mcp.json: Permission denied" before
-# the command runs. The job still reports success, so the bot silently answers
-# nothing - which is what the "Fail if ..." steps catch.
-#
-# Only resolve-conflicts may change code, and only the merge it is handed: the
-# model there has no shell at all, and the commit and push are done by a
-# workflow step from the event payload, never by the model.
-
 on:
   issues:
     types: [opened]
@@ -815,11 +796,11 @@ jobs:
           fi
           base=$(gh pr view "$PR" --json baseRefName --jq '.baseRefName')
           head=$(gh pr view "$PR" --json headRefName --jq '.headRefName')
-          gh pr checkout "$PR"
           git config core.hooksPath /dev/null
           git config core.quotePath false
           git config user.name "github-actions[bot]"
           git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+          gh pr checkout "$PR"
           git fetch origin "$base"
           if git merge --no-commit --no-ff "origin/${base}"; then
             git merge --abort 2>/dev/null || true
@@ -838,6 +819,14 @@ jobs:
             git merge --abort 2>/dev/null || true
             hand_back "The merge of \`${base}\` failed without leaving a conflicted file, so it needs a human. Nothing was changed."
           fi
+          odd=$(printf '%s\n' "$files" | grep -vE '^[A-Za-z0-9._][A-Za-z0-9._/-]*$' || true)
+          if [ -n "$odd" ]; then
+            git merge --abort 2>/dev/null || true
+            hand_back "The merge of \`${base}\` conflicts over paths this job refuses to hand to its tooling:
+          $(printf '%s\n' "$odd" | sed 's/^/- /')
+
+          Nothing was changed. Resolve those by hand."
+          fi
           rules=""
           while IFS= read -r f; do
             [ -z "$f" ] && continue

+ 2 - 2
docs/architecture.md

@@ -369,8 +369,8 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
 | `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
 | `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
 | `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
-| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | IP-limit and Xray access/error log cleanup; traffic resets |
-| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
+| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
+| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
 | default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
 | default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
 | `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |

+ 3 - 0
docs/content/docs/en/config/inbounds.mdx

@@ -40,6 +40,9 @@ See [Clients](/docs/config/clients).
 Optionally cap total traffic and set an expiry date for the inbound, and choose a
 periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`,
 `weekly`, or `monthly`.
+
+For `monthly` resets, select a day from 1 to 31. If the selected day does not
+exist in a shorter month, the reset runs on that month's last day.
 </Step>
 
 </Steps>

+ 7 - 0
docs/content/docs/en/config/reality.mdx

@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
 - **Leaked private key.** Only ever distribute the **public** key to clients.
 - **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both
   the inbound client entry and the share link.
+- **Old client cores rejected by default.** An empty **Min Client Ver** is not
+  "no limit": Xray-core falls back to the built-in minimum of the core build you
+  run (26.3.27 in current releases) that keeps client TLS fingerprints fresh, so
+  third-party cores such as Mihomo and sing-box fail REALITY verification even
+  with a correct config — clients see timeouts while only Xray-core based apps
+  connect. Set it to `1.0.0` only if you must support them; that also re-admits
+  outdated fingerprints.
 
 </Callout>
 

+ 3 - 0
docs/content/docs/fa/config/inbounds.mdx

@@ -40,6 +40,9 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
 به‌صورت اختیاری می‌توانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
 تعیین کنید، و یک زمان‌بندی **بازنشانی ترافیک** دوره‌ای انتخاب کنید: `never`
 (پیش‌فرض)، `hourly`، `daily`، `weekly` یا `monthly`.
+
+برای بازنشانی `monthly`، روزی از ۱ تا ۳۱ انتخاب کنید. اگر آن روز در ماهی کوتاه‌تر
+وجود نداشته باشد، بازنشانی در آخرین روز همان ماه انجام می‌شود.
 </Step>
 
 </Steps>

+ 7 - 0
docs/content/docs/fa/config/reality.mdx

@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
 - **نشت کلید خصوصی.** فقط و فقط **کلید عمومی** را میان کلاینت‌ها توزیع کنید.
 - **جریان نادرست.** REALITY + XTLS-Vision به `flow = xtls-rprx-vision` هم در ورودیِ
   مدخل کلاینت و هم در لینک اشتراک‌گذاری نیاز دارد.
+- **هسته‌های قدیمی کلاینت به‌طور پیش‌فرض رد می‌شوند.** خالی گذاشتن
+  **حداقل نسخه کلاینت** به معنای «بدون محدودیت» نیست: Xray-core به حداقل داخلیِ
+  نسخهٔ هسته‌ای که اجرا می‌کنید (در نسخه‌های فعلی 26.3.27) بازمی‌گردد تا اثر انگشت‌های TLS کلاینت‌ها تازه
+  بمانند؛ در نتیجه هسته‌های شخص ثالث مانند Mihomo و sing-box حتی با پیکربندی
+  کاملاً درست در تأیید REALITY شکست می‌خورند — کلاینت‌ها تایم‌اوت می‌بینند و فقط
+  اپلیکیشن‌های مبتنی بر Xray-core وصل می‌شوند. تنها در صورت نیاز به پشتیبانی از
+  آن‌ها مقدار `1.0.0` را تنظیم کنید؛ این کار اثر انگشت‌های قدیمی را هم می‌پذیرد.
 
 </Callout>
 

+ 3 - 0
docs/content/docs/ru/config/inbounds.mdx

@@ -41,6 +41,9 @@ icon: ArrowDownToLine
 При необходимости ограничьте общий объём трафика и установите дату истечения для
 входящего подключения, а также выберите расписание периодического **сброса трафика**:
 `never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
+
+Для сброса `monthly` выберите день от 1 до 31. Если выбранного дня нет в более
+коротком месяце, сброс выполняется в последний день этого месяца.
 </Step>
 
 </Steps>

+ 8 - 0
docs/content/docs/ru/config/reality.mdx

@@ -123,6 +123,14 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
   ключ.
 - **Неправильный поток.** Для REALITY + XTLS-Vision нужен `flow = xtls-rprx-vision`
   как в записи клиента входящего подключения, так и в ссылке для подключения.
+- **Старые ядра клиентов отклоняются по умолчанию.** Пустое поле
+  **Мин. версия клиента** не означает «без ограничений»: Xray-core использует
+  встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах),
+  который поддерживает свежесть
+  TLS-отпечатков клиентов, поэтому сторонние ядра, такие как Mihomo и sing-box,
+  не проходят проверку REALITY даже при корректной конфигурации — клиенты видят
+  таймауты, а подключаются только приложения на базе Xray-core. Ставьте `1.0.0`,
+  только если они вам необходимы; это также допустит устаревшие отпечатки.
 
 </Callout>
 

+ 3 - 0
docs/content/docs/zh/config/inbounds.mdx

@@ -37,6 +37,9 @@ icon: ArrowDownToLine
 
 可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
 `never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
+
+选择 `monthly` 时,可以指定每月 1 至 31 日重置。如果当月没有指定日期,
+则在该月最后一天重置。
 </Step>
 
 </Steps>

+ 1 - 0
docs/content/docs/zh/config/reality.mdx

@@ -105,6 +105,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
 - **SNI 不匹配。** SNI / server names 必须与目标站点的真实证书匹配,否则握手会暴露伪装。
 - **私钥泄露。** 永远只把**公钥**分发给客户端。
 - **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
+- **旧客户端内核默认被拒。** **最小客户端版本**留空并不是“不限制”:Xray-core 会退回到所运行内核版本的内置最低值(当前版本为 26.3.27)以保证客户端 TLS 指纹的新鲜度,因此 Mihomo、sing-box 等第三方内核即使配置完全正确也会导致 REALITY 验证失败——表现为客户端超时,只有基于 Xray-core 的应用能连上。只有在必须支持它们时才填 `1.0.0`;这同时也会放行过时的指纹。
 
 </Callout>
 

+ 8 - 0
docs/public/openapi.json

@@ -411,6 +411,9 @@
             "maximum": 65535,
             "minimum": 1,
             "type": "integer"
+          },
+          "subShowIdentityOnAllLinks": {
+            "type": "boolean"
           }
         },
         "required": [
@@ -479,6 +482,7 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
+          "subShowIdentityOnAllLinks",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
@@ -916,6 +920,9 @@
             "maximum": 65535,
             "minimum": 1,
             "type": "integer"
+          },
+          "subShowIdentityOnAllLinks": {
+            "type": "boolean"
           }
         },
         "required": [
@@ -991,6 +998,7 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
+          "subShowIdentityOnAllLinks",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",

+ 58 - 0
frontend/public/openapi.json

@@ -270,6 +270,9 @@
           "subRoutingRules": {
             "type": "string"
           },
+          "subShowIdentityOnAllLinks": {
+            "type": "boolean"
+          },
           "subSupportUrl": {
             "type": "string"
           },
@@ -441,6 +444,7 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
+          "subShowIdentityOnAllLinks",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
@@ -737,6 +741,9 @@
           "subRoutingRules": {
             "type": "string"
           },
+          "subShowIdentityOnAllLinks": {
+            "type": "boolean"
+          },
           "subSupportUrl": {
             "type": "string"
           },
@@ -915,6 +922,7 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
+          "subShowIdentityOnAllLinks",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
@@ -1860,6 +1868,13 @@
             ],
             "type": "string"
           },
+          "trafficResetDay": {
+            "description": "Day of month for monthly traffic resets",
+            "example": 1,
+            "maximum": 31,
+            "minimum": 1,
+            "type": "integer"
+          },
           "up": {
             "description": "Upload traffic in bytes",
             "format": "int64",
@@ -1886,6 +1901,7 @@
           "tag",
           "total",
           "trafficReset",
+          "trafficResetDay",
           "up"
         ],
         "type": "object"
@@ -3151,6 +3167,7 @@
                       "tag": "in-443-tcp",
                       "total": 0,
                       "trafficReset": "never",
+                      "trafficResetDay": 1,
                       "up": 0
                     }
                   ]
@@ -5816,6 +5833,47 @@
         }
       }
     },
+    "/panel/api/clients/get/tgId/{tgId}": {
+      "get": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.",
+        "operationId": "get_panel_api_clients_get_tgId_tgId",
+        "parameters": [
+          {
+            "name": "tgId",
+            "in": "path",
+            "required": true,
+            "description": "Telegram user ID (numeric).",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/add": {
       "post": {
         "tags": [

+ 2 - 0
frontend/src/components/form/DateTimePicker.tsx

@@ -121,7 +121,9 @@ export default function DateTimePicker({
     <DatePicker
       value={value}
       onChange={(next) => onChange(next || null)}
+      onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
       showTime={showTime ? { format: 'HH:mm:ss' } : false}
+      needConfirm={false}
       format={format}
       placeholder={placeholder}
       disabled={disabled}

+ 3 - 0
frontend/src/generated/examples.ts

@@ -75,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subPort": 1,
     "subProfileUrl": "",
     "subRoutingRules": "",
+    "subShowIdentityOnAllLinks": false,
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
@@ -186,6 +187,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subPort": 1,
     "subProfileUrl": "",
     "subRoutingRules": "",
+    "subShowIdentityOnAllLinks": false,
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
@@ -448,6 +450,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "tag": "in-443-tcp",
     "total": 0,
     "trafficReset": "never",
+    "trafficResetDay": 1,
     "up": 0
   },
   "InboundClientIps": {

+ 16 - 0
frontend/src/generated/schemas.ts

@@ -244,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subRoutingRules": {
         "type": "string"
       },
+      "subShowIdentityOnAllLinks": {
+        "type": "boolean"
+      },
       "subSupportUrl": {
         "type": "string"
       },
@@ -415,6 +418,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subPort",
       "subProfileUrl",
       "subRoutingRules",
+      "subShowIdentityOnAllLinks",
       "subSupportUrl",
       "subThemeDir",
       "subTitle",
@@ -711,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subRoutingRules": {
         "type": "string"
       },
+      "subShowIdentityOnAllLinks": {
+        "type": "boolean"
+      },
       "subSupportUrl": {
         "type": "string"
       },
@@ -889,6 +896,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subPort",
       "subProfileUrl",
       "subRoutingRules",
+      "subShowIdentityOnAllLinks",
       "subSupportUrl",
       "subThemeDir",
       "subTitle",
@@ -1834,6 +1842,13 @@ export const SCHEMAS: Record<string, unknown> = {
         ],
         "type": "string"
       },
+      "trafficResetDay": {
+        "description": "Day of month for monthly traffic resets",
+        "example": 1,
+        "maximum": 31,
+        "minimum": 1,
+        "type": "integer"
+      },
       "up": {
         "description": "Upload traffic in bytes",
         "format": "int64",
@@ -1860,6 +1875,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "tag",
       "total",
       "trafficReset",
+      "trafficResetDay",
       "up"
     ],
     "type": "object"

+ 3 - 0
frontend/src/generated/types.ts

@@ -81,6 +81,7 @@ export interface AllSetting {
   subPort: number;
   subProfileUrl: string;
   subRoutingRules: string;
+  subShowIdentityOnAllLinks: boolean;
   subSupportUrl: string;
   subThemeDir: string;
   subTitle: string;
@@ -193,6 +194,7 @@ export interface AllSettingView {
   subPort: number;
   subProfileUrl: string;
   subRoutingRules: string;
+  subShowIdentityOnAllLinks: boolean;
   subSupportUrl: string;
   subThemeDir: string;
   subTitle: string;
@@ -426,6 +428,7 @@ export interface Inbound {
   tag: string;
   total: number;
   trafficReset: string;
+  trafficResetDay: number;
   up: number;
 }
 

+ 3 - 0
frontend/src/generated/zod.ts

@@ -93,6 +93,7 @@ export const AllSettingSchema = z.object({
   subPort: z.number().int().min(1).max(65535),
   subProfileUrl: z.string(),
   subRoutingRules: z.string(),
+  subShowIdentityOnAllLinks: z.boolean(),
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
@@ -206,6 +207,7 @@ export const AllSettingViewSchema = z.object({
   subPort: z.number().int().min(1).max(65535),
   subProfileUrl: z.string(),
   subRoutingRules: z.string(),
+  subShowIdentityOnAllLinks: z.boolean(),
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
@@ -451,6 +453,7 @@ export const InboundSchema = z.object({
   tag: z.string(),
   total: z.number().int(),
   trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
+  trafficResetDay: z.number().int().min(1).max(31),
   up: z.number().int(),
 });
 export type Inbound = z.infer<typeof InboundSchema>;

+ 17 - 10
frontend/src/hooks/useClients.ts

@@ -117,6 +117,19 @@ export function computeClientsSummary(
   return { total: stats.length, active, online, depleted, expiring, deactive };
 }
 
+export function pickClientsSummary(
+  serverSummary: ClientsSummary,
+  allClientStats: ClientStatRow[],
+  onlineSet: Set<string>,
+  expireDiffMs: number,
+  trafficDiffBytes: number,
+): ClientsSummary {
+  if (allClientStats.length === 0) return serverSummary;
+  if (serverSummary.total > allClientStats.length) return serverSummary;
+  const live = computeClientsSummary(allClientStats, onlineSet, expireDiffMs, trafficDiffBytes);
+  return { ...live, total: serverSummary.total || live.total };
+}
+
 function buildQS(p: ClientQueryParams): string {
   const sp = new URLSearchParams();
   sp.set('page', String(p.page || 1));
@@ -265,18 +278,12 @@ export function useClients() {
   const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
   const pageSize = (defaults.pageSize as number) ?? 0;
 
-  // Live summary: the client_stats WS event refreshes allClientStats every few
-  // seconds, so the top counters track reality without a page refresh. Falls
-  // back to the server-computed summary until the first event lands, and keeps
-  // the server's authoritative total for the headline count.
   const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
   const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
-  const summary = useMemo<ClientsSummary>(() => {
-    const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
-    if (allClientStats.length === 0) return serverSummary;
-    const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff);
-    return { ...live, total: serverSummary.total || live.total };
-  }, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
+  const summary = useMemo<ClientsSummary>(
+    () => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
+    [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
+  );
 
   const invalidateAll = useCallback(
     () => {

+ 4 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -41,6 +41,7 @@ export interface RawInboundRow {
   enable?: boolean;
   expiryTime?: number;
   trafficReset?: string;
+  trafficResetDay?: number;
   lastTrafficResetTime?: number;
   nodeId?: number | null;
   shareAddrStrategy?: string;
@@ -60,6 +61,7 @@ export interface WireInboundPayload {
   enable: boolean;
   expiryTime: number;
   trafficReset: TrafficReset;
+  trafficResetDay: number;
   lastTrafficResetTime: number;
   listen: string;
   port: number;
@@ -202,6 +204,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     down: row.down ?? 0,
     total: row.total ?? 0,
     trafficReset: coerceTrafficReset(row.trafficReset),
+    trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
     lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
     nodeId: row.nodeId ?? null,
     shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
@@ -344,6 +347,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
     enable: values.enable,
     expiryTime: values.expiryTime,
     trafficReset: values.trafficReset,
+    trafficResetDay: values.trafficResetDay,
     lastTrafficResetTime: values.lastTrafficResetTime,
     listen: values.listen,
     port: values.port,

+ 10 - 2
frontend/src/lib/xray/inbound-link.ts

@@ -42,8 +42,7 @@ function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
 // Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
 // compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
 // scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
-// reading the share link wouldn't honor them. Mirrors the legacy
-// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
+// reading the share link wouldn't honor them.
 function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
   if (!xhttp) return null;
   const extra: Record<string, unknown> = {};
@@ -85,6 +84,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
     const v = xhttp[k];
     if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
   }
+  // xray-core #6258 renamed these fields, but older clients still read the
+  // legacy names from share-link extra. Emit both names so one link works
+  // across old and new clients while the stored panel config stays canonical.
+  if (typeof extra.sessionIDPlacement === 'string') {
+    extra.sessionPlacement = extra.sessionIDPlacement;
+  }
+  if (typeof extra.sessionIDKey === 'string') {
+    extra.sessionKey = extra.sessionIDKey;
+  }
 
   // Headers on the wire are a record; emit them as a map upstream's
   // SplitHTTPConfig.headers expects, dropping Host (already on the URL).

+ 57 - 0
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -104,6 +104,63 @@ export function validateRealityTarget(target: string): string | undefined {
   return undefined;
 }
 
+/**
+ * Parses a REALITY client-version string the way xray-core's config loader
+ * does: one to three dot-separated numeric parts, each 0-255. Returns the
+ * parts padded to three entries, or undefined when the string is not a valid
+ * version.
+ */
+export function parseRealityClientVer(value: string): [number, number, number] | undefined {
+  const trimmed = value.trim();
+  if (!trimmed) return undefined;
+  const parts = trimmed.split('.');
+  if (parts.length > 3) return undefined;
+  const nums: number[] = [];
+  for (const part of parts) {
+    if (!/^\d+$/.test(part)) return undefined;
+    const n = Number(part);
+    if (n > 255) return undefined;
+    nums.push(n);
+  }
+  while (nums.length < 3) nums.push(0);
+  return nums as [number, number, number];
+}
+
+/**
+ * Validates a REALITY client-version field; empty means "not set" and is
+ * valid. The value is saved exactly as typed and xray-core's part parser
+ * accepts no surrounding whitespace, so a value that differs from its
+ * trimmed form is rejected rather than silently passed to the wire.
+ */
+export function validateRealityClientVer(value: string): string | undefined {
+  if (!value) return undefined;
+  if (value !== value.trim() || !parseRealityClientVer(value)) {
+    return 'pages.inbounds.form.clientVerInvalid';
+  }
+  return undefined;
+}
+
+/**
+ * Validates the max client-version field: format first, then that a non-empty
+ * max is not below a non-empty min (an inverted range rejects every client).
+ * An empty or malformed min is left to the min field's own validation.
+ */
+export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
+  const formatError = validateRealityClientVer(max);
+  if (formatError) return formatError;
+  const maxParts = parseRealityClientVer(max);
+  const minParts = parseRealityClientVer(min);
+  if (!maxParts || !minParts) return undefined;
+  for (let i = 0; i < 3; i++) {
+    if (maxParts[i] !== minParts[i]) {
+      return maxParts[i] < minParts[i]
+        ? 'pages.inbounds.form.maxClientVerBelowMin'
+        : undefined;
+    }
+  }
+  return undefined;
+}
+
 function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
   const lift = (legacy: string, renamed: string) => {
     const v = obj[legacy];

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -30,6 +30,7 @@ export type DBInboundInit = Partial<{
     enable: boolean;
     expiryTime: number;
     trafficReset: string;
+    trafficResetDay: number;
     lastTrafficResetTime: number;
     listen: string;
     port: number;
@@ -76,6 +77,7 @@ export class DBInbound {
     enable: boolean;
     expiryTime: number;
     trafficReset: string;
+    trafficResetDay: number;
     lastTrafficResetTime: number;
 
     listen: string;
@@ -105,6 +107,7 @@ export class DBInbound {
         this.enable = true;
         this.expiryTime = 0;
         this.trafficReset = "never";
+        this.trafficResetDay = 1;
         this.lastTrafficResetTime = 0;
 
         this.listen = "";

+ 1 - 0
frontend/src/models/setting.ts

@@ -14,6 +14,7 @@ export class AllSetting {
   expireDiff = 0;
   trafficDiff = 0;
   remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
+  subShowIdentityOnAllLinks = false;
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotToken = '';

+ 10 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -582,6 +582,16 @@ export const sections: readonly Section[] = [
         response:
           '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5],\n    "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n  }\n}',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/get/tgId/:tgId',
+        summary: 'Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.',
+        params: [
+          { name: 'tgId', in: 'path', type: 'integer', desc: 'Telegram user ID (numeric).' },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    {\n      "client": { "id": 1, "email": "[email protected]", ... },\n      "inboundIds": [3, 5],\n      "externalLinks": [],\n      "usedTraffic": 1048576\n    }\n  ]\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/add',

+ 1 - 4
frontend/src/pages/clients/ClientsPage.tsx

@@ -205,7 +205,7 @@ export default function ClientsPage() {
 
   const {
     clients, total, filtered,
-    summary: serverSummary,
+    summary,
     allGroups,
     setQuery,
     inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
@@ -385,9 +385,6 @@ export default function ClientsPage() {
   // a rename.
   const filteredClients = clients;
 
-  // Server-computed counts that stay stable as the user paginates/filters.
-  const summary = serverSummary;
-
   // Sort is server-side now; the page already arrives in the requested
   // order, so we just hand it through.
   const sortedClients = filteredClients;

+ 12 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -33,6 +33,7 @@ import {
   isSS2022,
 } from '@/lib/xray/protocol-capabilities';
 import {
+  InboundDbFieldsSchema,
   InboundFormBaseSchema,
   InboundFormSchema,
   type InboundFormValues,
@@ -255,6 +256,7 @@ export default function InboundFormModal({
   const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
   const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
   const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
+  const trafficReset = useWatch({ control, name: 'trafficReset' }) ?? 'never';
   const autoTagRef = useRef(true);
   const lastWrittenTagRef = useRef('');
   const currentTagInput = (): InboundTagInput => ({
@@ -619,6 +621,16 @@ export default function InboundFormModal({
         />
       </FormField>
 
+      {trafficReset === 'monthly' && (
+        <FormField
+          name="trafficResetDay"
+          label={t('pages.inbounds.periodicTrafficResetDay')}
+          rules={{ validate: rhfZodValidate(InboundDbFieldsSchema.shape.trafficResetDay) }}
+        >
+          <InputNumber min={1} max={31} />
+        </FormField>
+      )}
+
       <Form.Item
         label={
           <Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>

+ 31 - 2
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -1,11 +1,16 @@
 import { useState } from 'react';
+import { useFormContext } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
 
 import { FormField } from '@/components/form/rhf';
 import { UTLS_FINGERPRINT } from '@/schemas/primitives';
-import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
+import {
+  validateRealityClientVer,
+  validateRealityMaxClientVer,
+  validateRealityTarget,
+} from '@/lib/xray/stream-wire-normalize';
 import type { RealityScanResult } from '@/generated/types';
 import RealityTargetScannerModal from './RealityTargetScannerModal';
 
@@ -39,7 +44,14 @@ export default function RealityForm({
   clearMldsa65,
 }: RealityFormProps) {
   const { t } = useTranslation();
+  const { getFieldState, trigger } = useFormContext();
   const [scannerOpen, setScannerOpen] = useState(false);
+  const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
+  const revalidateMaxClientVer = () => {
+    if (getFieldState(maxClientVerPath).error) {
+      void trigger(maxClientVerPath);
+    }
+  };
   return (
     <>
       <FormField
@@ -127,14 +139,31 @@ export default function RealityForm({
       <FormField
         name={['streamSettings', 'realitySettings', 'minClientVer']}
         label={t('pages.inbounds.form.minClientVer')}
+        tooltip={t('pages.inbounds.form.minClientVerHint')}
+        onAfterChange={revalidateMaxClientVer}
+        rules={{
+          validate: (value) => {
+            const errKey = validateRealityClientVer(typeof value === 'string' ? value : '');
+            return errKey ? errKey : true;
+          },
+        }}
       >
         <Input placeholder="26.3.27" />
       </FormField>
       <FormField
         name={['streamSettings', 'realitySettings', 'maxClientVer']}
         label={t('pages.inbounds.form.maxClientVer')}
+        tooltip={t('pages.inbounds.form.maxClientVerHint')}
+        rules={{
+          validate: (value, formValues) => {
+            const max = typeof value === 'string' ? value : '';
+            const min = formValues?.streamSettings?.realitySettings?.minClientVer;
+            const errKey = validateRealityMaxClientVer(max, typeof min === 'string' ? min : '');
+            return errKey ? errKey : true;
+          },
+        }}
       >
-        <Input placeholder="25.9.11" />
+        <Input placeholder="x.y.z" />
       </FormField>
       <Form.Item label={t('pages.inbounds.form.shortIds')}>
         <Space.Compact block style={{ display: 'flex' }}>

+ 2 - 1
frontend/src/pages/settings/EmailTab.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
 import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
 import { HttpUtil } from '@/utils';
+import { onNumber } from '@/utils/onNumber';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
@@ -64,7 +65,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
 
             <SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
               <InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
+                onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>

+ 10 - 9
frontend/src/pages/settings/GeneralTab.tsx

@@ -17,6 +17,7 @@ import {
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { HttpUtil, LanguageManager } from '@/utils';
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
@@ -170,7 +171,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
             <SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
               <InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ webPort: v }))} />
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
@@ -179,7 +180,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
             <SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
               <InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
             </SettingListItem>
 
             <SettingListItem
@@ -208,7 +209,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
             <SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
               <InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
@@ -234,11 +235,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
           <>
             <SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
               <InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
               <InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
             </SettingListItem>
           </>
         ),
@@ -308,7 +309,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
               <InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
               <Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
@@ -387,15 +388,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
               <InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
               <InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
               <InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
             </SettingListItem>
           </>
         ),

+ 3 - 2
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -17,6 +17,7 @@ import {
   SettingOutlined,
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { GoRegexInput } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
               <div className="format-settings">
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
                   <InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
-                    onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
+                    onChange={onNumber((v) => setMuxField('concurrency', v))} />
                 </SettingListItem>
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
                   <InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
-                    onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
+                    onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
                 </SettingListItem>
                 <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
                   <Select

+ 13 - 2
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -3,6 +3,7 @@ import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined,
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router';
 import type { AllSetting } from '@/models/setting';
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { RemarkTemplateField } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -57,7 +58,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
               <InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ subPort: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ subPort: v }))} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
               <Input
@@ -93,10 +94,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
                 maxLength={256}
               />
             </SettingListItem>
+            <SettingListItem
+              paddings="small"
+              title={t('pages.settings.subShowIdentityOnAllLinks')}
+              description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
+            >
+              <Switch
+                checked={allSetting.subShowIdentityOnAllLinks}
+                onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
+              />
+            </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
               <InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
+                onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
             </SettingListItem>
           </>
         ),

+ 2 - 1
frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx

@@ -2,6 +2,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
 
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import {
   BurstObservatorySchema,
@@ -195,7 +196,7 @@ export default function ObservatorySettingsTab({
         <InputNumber
           min={1}
           value={burst.pingConfig.sampling}
-          onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
+          onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
           style={{ width: '100%' }}
         />
       </SettingListItem>

+ 2 - 1
frontend/src/pages/xray/dns/DnsTab.tsx

@@ -11,6 +11,7 @@ import {
   SettingOutlined,
 } from '@ant-design/icons';
 
+import { onNumber } from '@/utils/onNumber';
 import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from '@/pages/settings/catTabLabel';
@@ -311,7 +312,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
                       min={0}
                       step={60}
                       style={{ width: '100%' }}
-                      onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
+                      onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
                     />
                   }
                 />

+ 3 - 1
frontend/src/pages/xray/dns/useDnsColumns.tsx

@@ -4,6 +4,8 @@ import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
 import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
+import { onNumber } from '@/utils/onNumber';
+
 import { addrFor, domainsFor, expectedIPsFor } from './helpers';
 import type { DnsServerValue } from './DnsServerModal';
 
@@ -113,7 +115,7 @@ export function useFakednsColumns({
             aria-label={t('pages.xray.fakedns.poolSize')}
             min={1}
             size="small"
-            onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
+            onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
           />
         ),
       },

+ 3 - 2
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -38,6 +38,7 @@ import {
 } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
+import { onNumber } from '@/utils/onNumber';
 import PromptModal from '@/components/feedback/PromptModal';
 import TextModal from '@/components/feedback/TextModal';
 
@@ -626,14 +627,14 @@ export default function OutboundsTab({
                   <InputNumber
                     min={0}
                     value={intervalHours}
-                    onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
+                    onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
                     style={{ width: 80 }}
                   /> {t('pages.xray.outboundSub.hours')}
                   <InputNumber
                     min={0}
                     max={59}
                     value={intervalMinutes}
-                    onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
+                    onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
                     style={{ width: 80 }}
                   /> {t('pages.xray.outboundSub.minutes')}
                 </Space>

+ 1 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -22,6 +22,7 @@ export const InboundDbFieldsSchema = z.object({
   down: z.number().int().min(0).default(0),
   total: z.number().int().min(0).default(0),
   trafficReset: TrafficResetSchema.default('never'),
+  trafficResetDay: z.number().int().min(1).max(31).default(1),
   lastTrafficResetTime: z.number().int().default(0),
   nodeId: z.number().int().nullable().optional(),
   shareAddrStrategy: ShareAddrStrategySchema.default('node'),

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -18,6 +18,7 @@ export const AllSettingSchema = z.object({
   expireDiff: nonNegativeInt.optional(),
   trafficDiff: nonNegativeInt.max(100).optional(),
   remarkTemplate: z.string().optional(),
+  subShowIdentityOnAllLinks: z.boolean().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   tgBotEnable: z.boolean().optional(),
   tgBotToken: z.string().optional(),

+ 26 - 2
frontend/src/test/clients-summary.test.ts

@@ -1,7 +1,7 @@
 import { describe, it, expect } from 'vitest';
 
-import { computeClientsSummary } from '@/hooks/useClients';
-import type { ClientTraffic } from '@/schemas/client';
+import { computeClientsSummary, pickClientsSummary } from '@/hooks/useClients';
+import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
 
 // Parity with web/service/client.go buildClientsSummary: the same client must
 // land in the same bucket whether the count comes from the server (list fetch)
@@ -60,3 +60,27 @@ describe('computeClientsSummary', () => {
     expect(s.depleted).toEqual([]);
   });
 });
+
+describe('pickClientsSummary', () => {
+  const serverSummary: ClientsSummary = {
+    total: 67, active: 58, online: [], depleted: [], expiring: [], deactive: [],
+  };
+
+  it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
+    const shortSnapshot: Row[] = Array.from({ length: 58 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
+    const s = pickClientsSummary(serverSummary, shortSnapshot, new Set(), 3 * DAY, 1 * GB);
+    expect(s).toEqual(serverSummary);
+  });
+
+  it('uses the live recompute when the snapshot covers every client', () => {
+    const fullSnapshot: Row[] = Array.from({ length: 67 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
+    const s = pickClientsSummary(serverSummary, fullSnapshot, new Set(), 3 * DAY, 1 * GB);
+    expect(s.total).toBe(67);
+    expect(s.active).toBe(67);
+  });
+
+  it('falls back to the server summary before the first WS snapshot arrives', () => {
+    const s = pickClientsSummary(serverSummary, [], new Set(), 3 * DAY, 1 * GB);
+    expect(s).toEqual(serverSummary);
+  });
+});

+ 43 - 0
frontend/src/test/date-time-picker.test.tsx

@@ -0,0 +1,43 @@
+import { fireEvent } from '@testing-library/react';
+import dayjs from 'dayjs';
+import type { Dayjs } from 'dayjs';
+import { describe, expect, it, vi } from 'vitest';
+
+import DateTimePicker from '@/components/form/DateTimePicker';
+import { renderWithProviders } from './test-utils';
+
+function openPicker(): void {
+  const input = document.querySelector('.ant-picker input');
+  if (!input) throw new Error('picker input not rendered');
+  fireEvent.mouseDown(input);
+  fireEvent.click(input);
+}
+
+function clickDayCell(title: string): void {
+  const cell = document.querySelector(`.ant-picker-cell[title="${title}"] .ant-picker-cell-inner`);
+  if (!cell) throw new Error(`day cell ${title} not rendered`);
+  fireEvent.click(cell);
+}
+
+describe('DateTimePicker', () => {
+  it('commits a clicked calendar date without an OK press', () => {
+    const onChange = vi.fn<(next: Dayjs | null) => void>();
+    renderWithProviders(<DateTimePicker value={null} onChange={onChange} />);
+
+    openPicker();
+    const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD');
+    clickDayCell(tomorrow);
+
+    expect(onChange).toHaveBeenCalled();
+    const committed = onChange.mock.calls.at(-1)?.[0];
+    expect(committed?.format('YYYY-MM-DD HH:mm:ss')).toBe(`${tomorrow} 00:00:00`);
+  });
+
+  it('renders no OK confirm button in the picker footer', () => {
+    renderWithProviders(<DateTimePicker value={null} onChange={vi.fn()} />);
+
+    openPicker();
+
+    expect(document.querySelector('.ant-picker-ok')).toBeNull();
+  });
+});

+ 40 - 0
frontend/src/test/general-tab.test.tsx

@@ -0,0 +1,40 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AllSetting } from '@/models/setting';
+import GeneralTab from '@/pages/settings/GeneralTab';
+import { renderWithProviders } from './test-utils';
+
+describe('GeneralTab', () => {
+  it('keeps the stored page size when the field is cleared', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings']}>
+        <GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
+      </MemoryRouter>,
+    );
+
+    const pageSizeInput = screen.getByDisplayValue('25');
+    fireEvent.change(pageSizeInput, { target: { value: '' } });
+    fireEvent.blur(pageSizeInput);
+
+    expect(updateSetting).not.toHaveBeenCalled();
+    expect((pageSizeInput as HTMLInputElement).value).toBe('25');
+  });
+
+  it('forwards typed page sizes unchanged, zero included', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings']}>
+        <GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
+      </MemoryRouter>,
+    );
+
+    fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
+
+    expect(updateSetting).toHaveBeenCalledWith({ pageSize: 0 });
+  });
+});

+ 7 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -32,6 +32,7 @@ const vlessRow: RawInboundRow = {
   total: 1_000_000_000,
   expiryTime: 0,
   trafficReset: 'monthly',
+  trafficResetDay: 15,
   lastTrafficResetTime: 0,
   tag: 'inbound-1',
   nodeId: null,
@@ -267,6 +268,7 @@ describe('formValuesToWirePayload', () => {
       enable: payload.enable,
       expiryTime: payload.expiryTime,
       trafficReset: payload.trafficReset,
+      trafficResetDay: payload.trafficResetDay,
       lastTrafficResetTime: payload.lastTrafficResetTime,
       nodeId: payload.nodeId ?? null,
     });
@@ -276,8 +278,13 @@ describe('formValuesToWirePayload', () => {
     expect(replay.listen).toBe(original.listen);
     expect(replay.up).toBe(original.up);
     expect(replay.down).toBe(original.down);
+    expect(replay.trafficResetDay).toBe(original.trafficResetDay);
     expect(replay.streamSettings).toEqual(original.streamSettings);
   });
+
+  it('defaults a missing monthly reset day to the first', () => {
+    expect(rawInboundToFormValues({ ...vlessRow, trafficResetDay: undefined }).trafficResetDay).toBe(1);
+  });
 });
 
 describe('subSortIndex', () => {

+ 70 - 0
frontend/src/test/inbound-link.test.ts

@@ -745,3 +745,73 @@ describe('genVlessLink flow gating (#5322)', () => {
     expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
   });
 });
+
+describe('genVlessLink XHTTP extra compatibility', () => {
+  it('emits both sessionID and legacy session keys in XHTTP extra', () => {
+    const typed = InboundSchema.parse({
+      id: 1,
+      up: 0,
+      down: 0,
+      total: 0,
+      remark: 'xhttp-session',
+      enable: true,
+      expiryTime: 0,
+      listen: '',
+      port: 443,
+      tag: 'inbound-vless-xhttp',
+      sniffing: {
+        enabled: false,
+        destOverride: [],
+        metadataOnly: false,
+        routeOnly: false,
+        ipsExcluded: [],
+        domainsExcluded: [],
+      },
+      protocol: 'vless',
+      settings: {
+        clients: [
+          {
+            id: '11111111-2222-3333-4444-555555555555',
+            email: '[email protected]',
+            flow: '',
+            limitIp: 0,
+            totalGB: 0,
+            expiryTime: 0,
+            enable: true,
+            tgId: 0,
+            subId: 's1',
+            comment: '',
+            reset: 0,
+          },
+        ],
+        decryption: 'none',
+        encryption: 'none',
+        fallbacks: [],
+      },
+      streamSettings: {
+        network: 'xhttp',
+        security: 'none',
+        xhttpSettings: {
+          path: '/sp',
+          host: 'edge.example.test',
+          mode: 'auto',
+          sessionIDPlacement: 'header',
+          sessionIDKey: 'X-Session',
+        },
+      },
+    });
+
+    const link = genVlessLink({
+      inbound: typed,
+      address: 'example.test',
+      port: 443,
+      clientId: '11111111-2222-3333-4444-555555555555',
+    });
+    const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record<string, unknown>;
+
+    expect(extra.sessionIDPlacement).toBe('header');
+    expect(extra.sessionIDKey).toBe('X-Session');
+    expect(extra.sessionPlacement).toBe('header');
+    expect(extra.sessionKey).toBe('X-Session');
+  });
+});

+ 23 - 0
frontend/src/test/on-number.test.ts

@@ -0,0 +1,23 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { onNumber } from '@/utils/onNumber';
+
+describe('onNumber', () => {
+  it('forwards numeric values, including zero and negatives', () => {
+    const apply = vi.fn();
+    const handler = onNumber(apply);
+    handler(8443);
+    handler(0);
+    handler(-1);
+    expect(apply.mock.calls).toEqual([[8443], [0], [-1]]);
+  });
+
+  it('ignores cleared events instead of writing a synthetic value', () => {
+    const apply = vi.fn();
+    const handler = onNumber(apply);
+    handler(null);
+    handler(undefined);
+    handler(NaN);
+    expect(apply).not.toHaveBeenCalled();
+  });
+});

+ 22 - 0
frontend/src/test/setting-sub-identity.test.ts

@@ -0,0 +1,22 @@
+import { describe, it, expect } from 'vitest';
+import { AllSettingSchema } from '@/schemas/setting';
+import { AllSetting } from '@/models/setting';
+
+describe('subShowIdentityOnAllLinks', () => {
+  it('defaults to false on AllSetting', () => {
+    expect(new AllSetting().subShowIdentityOnAllLinks).toBe(false);
+  });
+
+  it('accepts boolean values in the settings schema', () => {
+    for (const v of [true, false]) {
+      const r = AllSettingSchema.safeParse({ subShowIdentityOnAllLinks: v });
+      expect(r.success, `subShowIdentityOnAllLinks=${v}`).toBe(true);
+    }
+  });
+
+  it('rejects non-boolean values', () => {
+    for (const v of ['true', 1, null]) {
+      expect(AllSettingSchema.safeParse({ subShowIdentityOnAllLinks: v }).success).toBe(false);
+    }
+  });
+});

+ 60 - 0
frontend/src/test/stream-wire-normalize.test.ts

@@ -7,6 +7,8 @@ import {
   normalizeSockoptForWire,
   normalizeStreamSettingsForWire,
   normalizeXhttpForWire,
+  validateRealityClientVer,
+  validateRealityMaxClientVer,
   validateRealityTarget,
 } from '@/lib/xray/stream-wire-normalize';
 import { InboundFormSchema } from '@/schemas/forms/inbound-form';
@@ -26,6 +28,64 @@ describe('validateRealityTarget', () => {
   });
 });
 
+describe('validateRealityClientVer', () => {
+  it('accepts empty (not set) and core-style versions', () => {
+    expect(validateRealityClientVer('')).toBeUndefined();
+    expect(validateRealityClientVer('26.3.27')).toBeUndefined();
+    expect(validateRealityClientVer('1.0.0')).toBeUndefined();
+    expect(validateRealityClientVer('26')).toBeUndefined();
+    expect(validateRealityClientVer('26.3')).toBeUndefined();
+    expect(validateRealityClientVer('0.0.255')).toBeUndefined();
+  });
+
+  it('rejects untrimmed values because the save path ships them verbatim', () => {
+    expect(validateRealityClientVer('26.3.27 ')).toBe('pages.inbounds.form.clientVerInvalid');
+    expect(validateRealityClientVer(' 26.3.27')).toBe('pages.inbounds.form.clientVerInvalid');
+    expect(validateRealityClientVer(' ')).toBe('pages.inbounds.form.clientVerInvalid');
+  });
+
+  it('rejects what the core parser rejects', () => {
+    expect(validateRealityClientVer('26.3.27.1')).toBe('pages.inbounds.form.clientVerInvalid');
+    expect(validateRealityClientVer('26.3.256')).toBe('pages.inbounds.form.clientVerInvalid');
+    expect(validateRealityClientVer('v26.3.27')).toBe('pages.inbounds.form.clientVerInvalid');
+    expect(validateRealityClientVer('26..27')).toBe('pages.inbounds.form.clientVerInvalid');
+    expect(validateRealityClientVer('26.3.')).toBe('pages.inbounds.form.clientVerInvalid');
+    expect(validateRealityClientVer('-1.0.0')).toBe('pages.inbounds.form.clientVerInvalid');
+  });
+});
+
+describe('validateRealityMaxClientVer', () => {
+  it('accepts an empty max, an empty min, and a valid range', () => {
+    expect(validateRealityMaxClientVer('', '26.3.27')).toBeUndefined();
+    expect(validateRealityMaxClientVer('27.0.0', '')).toBeUndefined();
+    expect(validateRealityMaxClientVer('26.3.27', '26.3.27')).toBeUndefined();
+    expect(validateRealityMaxClientVer('27.1.2', '26.3.27')).toBeUndefined();
+  });
+
+  it('rejects a max below the min, the stale-placeholder trap included', () => {
+    expect(validateRealityMaxClientVer('25.9.11', '26.3.27')).toBe(
+      'pages.inbounds.form.maxClientVerBelowMin',
+    );
+    expect(validateRealityMaxClientVer('26.3.26', '26.3.27')).toBe(
+      'pages.inbounds.form.maxClientVerBelowMin',
+    );
+  });
+
+  it('pads short versions like the core does before comparing', () => {
+    expect(validateRealityMaxClientVer('26', '26.0.0')).toBeUndefined();
+    expect(validateRealityMaxClientVer('26', '26.3')).toBe(
+      'pages.inbounds.form.maxClientVerBelowMin',
+    );
+  });
+
+  it('reports format errors before range errors and skips a malformed min', () => {
+    expect(validateRealityMaxClientVer('25.9', 'not-a-version')).toBeUndefined();
+    expect(validateRealityMaxClientVer('nope', '26.3.27')).toBe(
+      'pages.inbounds.form.clientVerInvalid',
+    );
+  });
+});
+
 describe('normalizeXhttpForWire stream-one', () => {
   it('drops packet-up and stream-up-only fields on inbound', () => {
     const out = normalizeXhttpForWire({

+ 31 - 0
frontend/src/test/subscription-general-tab.test.tsx

@@ -12,6 +12,37 @@ function LocationProbe() {
 }
 
 describe('SubscriptionGeneralTab', () => {
+  it('keeps the stored subscription port when the field is cleared', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings#subscription']}>
+        <SubscriptionGeneralTab allSetting={new AllSetting({ subPort: 2096 })} updateSetting={updateSetting} />
+      </MemoryRouter>,
+    );
+
+    const portInput = screen.getByDisplayValue('2096');
+    fireEvent.change(portInput, { target: { value: '' } });
+    fireEvent.blur(portInput);
+
+    expect(updateSetting).not.toHaveBeenCalled();
+    expect((portInput as HTMLInputElement).value).toBe('2096');
+  });
+
+  it('forwards typed subscription ports unchanged', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings#subscription']}>
+        <SubscriptionGeneralTab allSetting={new AllSetting({ subPort: 2096 })} updateSetting={updateSetting} />
+      </MemoryRouter>,
+    );
+
+    fireEvent.change(screen.getByDisplayValue('2096'), { target: { value: '8443' } });
+
+    expect(updateSetting).toHaveBeenCalledWith({ subPort: 8443 });
+  });
+
   it('uses router navigation to open subscription format settings', () => {
     const allSetting = new AllSetting({ subClashEnable: true });
 

+ 16 - 0
frontend/src/utils/onNumber.ts

@@ -0,0 +1,16 @@
+/**
+ * Wraps an Ant Design InputNumber change handler with the shared
+ * cleared-field semantic: null and undefined change events (a cleared or
+ * unparsable field) are ignored, leaving the stored value in place — the
+ * input snaps back on blur — while real numbers pass through unchanged.
+ * Number-only by design: not for `stringMode` inputs, whose whole point is
+ * to avoid the IEEE-754 round trip this signature would force.
+ */
+export function onNumber(
+  apply: (value: number) => void,
+): (value: number | null | undefined) => void {
+  return (value) => {
+    if (typeof value !== 'number' || !Number.isFinite(value)) return;
+    apply(value);
+  };
+}

+ 1 - 3
install.sh

@@ -6,8 +6,6 @@ blue='\033[0;34m'
 yellow='\033[0;33m'
 plain='\033[0m'
 
-cur_dir=$(pwd)
-
 xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}"
 xui_service="${XUI_SERVICE:=/etc/systemd/system}"
 
@@ -36,7 +34,7 @@ arch() {
         armv6* | armv6) echo 'armv6' ;;
         armv5* | armv5) echo 'armv5' ;;
         s390x) echo 's390x' ;;
-        *) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;;
+        *) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f "$(realpath "$0")" && exit 1 ;;
     esac
 }
 

+ 14 - 0
internal/database/db.go

@@ -131,6 +131,9 @@ func initModels() error {
 	if err := migrateVmessRemovedSecurities(); err != nil {
 		return err
 	}
+	if err := migrateTgIDIndex(); err != nil {
+		return err
+	}
 	if IsPostgres() {
 		if err := resyncPostgresSequences(db, models); err != nil {
 			log.Printf("Error resyncing postgres sequences: %v", err)
@@ -884,6 +887,17 @@ func migrateVmessRemovedSecurities() error {
 	return nil
 }
 
+// migrateTgIDIndex creates an index on the clients.tg_id column so that
+// lookups by Telegram ID do not require a full table scan. The index tag
+// on the struct field already causes AutoMigrate to create it on new
+// installations; the explicit migration ensures existing databases get it.
+func migrateTgIDIndex() error {
+	if db.Migrator().HasIndex(&model.ClientRecord{}, "idx_clients_tg_id") {
+		return nil
+	}
+	return db.Migrator().CreateIndex(&model.ClientRecord{}, "TgID")
+}
+
 // normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
 // minimum (rows written by builds that defaulted the column to 0, or by nodes
 // predating the field) so they cannot sort ahead of explicitly ranked inbounds.

+ 2 - 1
internal/database/model/model.go

@@ -54,6 +54,7 @@ type Inbound struct {
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"`                                                                         // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                                                                                 // Expiration timestamp
 	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
+	TrafficResetDay      int                  `json:"trafficResetDay" form:"trafficResetDay" gorm:"default:1" validate:"omitempty,gte=1,lte=31" example:"1"`                                                        // Day of month for monthly traffic resets
 	LastTrafficResetTime int64                `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`                                                                                            // Last traffic reset timestamp
 	ClientStats          []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`                                                                                     // Client traffic statistics
 
@@ -904,7 +905,7 @@ type ClientRecord struct {
 	TotalGB      int64  `json:"totalGB" gorm:"column:total_gb"`
 	ExpiryTime   int64  `json:"expiryTime" gorm:"column:expiry_time"`
 	Enable       bool   `json:"enable" gorm:"default:true"`
-	TgID         int64  `json:"tgId" gorm:"column:tg_id"`
+	TgID         int64  `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
 	Group        string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
 	Comment      string `json:"comment"`
 	Reset        int    `json:"reset" gorm:"default:0"`

+ 79 - 22
internal/sub/remark_vars.go

@@ -40,6 +40,26 @@ func (ctx remarkContext) configName() string {
 // underscores only, so ordinary braces in a remark are left untouched.
 var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)
 
+// remarkToken is one {{TOKEN}} occurrence: its name and the byte range it spans
+// in the segment it was found in.
+type remarkToken struct {
+	name  string
+	start int
+	end   int
+}
+
+// remarkTokens locates every {{TOKEN}} in seg. Both the template-level filter and
+// the value-level expansion walk a segment through this, so they share one notion
+// of where a token begins and ends and what the literal text between two of them is.
+func remarkTokens(seg string) []remarkToken {
+	locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
+	tokens := make([]remarkToken, len(locs))
+	for i, loc := range locs {
+		tokens[i] = remarkToken{name: seg[loc[2]:loc[3]], start: loc[0], end: loc[1]}
+	}
+	return tokens
+}
+
 // unlimitedMark is the value the human-readable quota/expiry tokens render when
 // the client has no limit. A segment built only around such a token carries no
 // information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
@@ -107,7 +127,9 @@ func translateUISingleBrackets(template string) string {
 // value. Unknown tokens resolve to "" (never the literal text). The template is
 // split on "|" into segments: a segment whose only value is an unlimited quota
 // or expiry (∞) drops out whole — decoration and separator included — so an
-// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
+// unlimited client gets "host" instead of "host|📊∞|⏳∞D". Inside a surviving
+// segment expandSegment also elides a hyphen separator an empty token would
+// leave dangling.
 func expandRemarkVars(template string, ctx remarkContext) string {
 	template = translateUISingleBrackets(template)
 	if !strings.Contains(template, "{{") {
@@ -129,18 +151,43 @@ func expandRemarkVars(template string, ctx remarkContext) string {
 // — so it leaves no stray "|" separator or dangling decoration. A segment mixing,
 // say, {{EMAIL}} with {{TRAFFIC_LEFT}} is kept, and a pure-literal segment (no
 // tokens) is always kept.
+//
+// A hyphen standing alone between two adjacent tokens is treated as their
+// separator and elided when no token before it has produced a value yet or when
+// the token after it resolves to nothing. "{{INBOUND}}-{{EMAIL}}" gives "john"
+// for an inbound with no remark, "🌐{{INBOUND}}-{{EMAIL}}" gives "🌐john" so
+// leading decoration does not keep the separator alive, and
+// "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}" keeps a single separator when the middle
+// token is empty. A hyphen anywhere else in the segment is literal text and is
+// kept as written.
 func expandSegment(seg string, ctx remarkContext) (string, bool) {
-	hasToken, hasOtherValue := false, false
-	out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
-		hasToken = true
-		token := m[2 : len(m)-2]
-		val := remarkVarValue(token, ctx)
-		if val != "" && (!unlimitedDropTokens[token] || val != unlimitedMark) {
+	tokens := remarkTokens(seg)
+	hasToken, hasOtherValue := len(tokens) > 0, false
+	values := make([]string, len(tokens))
+	for i, tok := range tokens {
+		val := remarkVarValue(tok.name, ctx)
+		values[i] = val
+		if val != "" && (!unlimitedDropTokens[tok.name] || val != unlimitedMark) {
 			hasOtherValue = true
 		}
-		return val
-	})
-	return out, hasToken && !hasOtherValue
+	}
+
+	var result strings.Builder
+	start, wroteValue := 0, false
+	for i, tok := range tokens {
+		result.WriteString(seg[start:tok.start])
+		result.WriteString(values[i])
+		wroteValue = wroteValue || values[i] != ""
+		start = tok.end
+		if i+1 < len(tokens) {
+			between := seg[start:tokens[i+1].start]
+			if strings.TrimSpace(between) == "-" && (!wroteValue || values[i+1] == "") {
+				start = tokens[i+1].start
+			}
+		}
+	}
+	result.WriteString(seg[start:])
+	return result.String(), hasToken && !hasOtherValue
 }
 
 func remarkVarValue(token string, ctx remarkContext) string {
@@ -511,11 +558,17 @@ func filterRemarkTemplate(template string, remove map[string]bool) string {
 	return strings.Join(kept, "|")
 }
 
+// filterRemarkSegment drops whole token categories from one segment while it is
+// still a template, before any value is known. Literal text touching a removed
+// token goes with it and the surviving runs rejoin with a space, so filtering the
+// usage tokens out of "{{EMAIL}} 📊{{TRAFFIC_LEFT}}" leaves "{{EMAIL}}". This is
+// the template-level counterpart to expandSegment, which works one layer later on
+// tokens that survive here but resolve to an empty value.
 func filterRemarkSegment(seg string, remove map[string]bool) string {
-	locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
+	tokens := remarkTokens(seg)
 	hasRemove := false
-	for _, loc := range locs {
-		if remove[seg[loc[2]:loc[3]]] {
+	for _, tok := range tokens {
+		if remove[tok.name] {
 			hasRemove = true
 			break
 		}
@@ -525,28 +578,28 @@ func filterRemarkSegment(seg string, remove map[string]bool) string {
 	}
 	runs := make([]string, 0, 2)
 	runStart, leftRemoved := 0, false
-	for _, loc := range locs {
-		if !remove[seg[loc[2]:loc[3]]] {
+	for _, tok := range tokens {
+		if !remove[tok.name] {
 			continue
 		}
-		runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
-		runStart, leftRemoved = loc[1], true
+		runs = appendKeptRun(runs, seg[runStart:tok.start], leftRemoved, true)
+		runStart, leftRemoved = tok.end, true
 	}
 	runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
 	return strings.Join(runs, " ")
 }
 
 func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
-	locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
-	if len(locs) == 0 {
+	tokens := remarkTokens(run)
+	if len(tokens) == 0 {
 		return runs
 	}
 	start, end := 0, len(run)
 	if leftRemoved {
-		start = locs[0][0]
+		start = tokens[0].start
 	}
 	if rightRemoved {
-		end = locs[len(locs)-1][1]
+		end = tokens[len(tokens)-1].end
 	}
 	if frag := strings.TrimSpace(run[start:end]); frag != "" {
 		runs = append(runs, frag)
@@ -560,7 +613,11 @@ func (s *SubService) effectiveTemplate(email string) string {
 		s.usageShown = map[string]bool{}
 	}
 	if s.usageShown[email] {
-		return filterRemarkTemplate(translated, firstLinkOnlyBodyTokens)
+		remove := firstLinkOnlyBodyTokens
+		if s.showIdentityOnAllLinks {
+			remove = usageInfoTokens
+		}
+		return filterRemarkTemplate(translated, remove)
 	}
 	s.usageShown[email] = true
 	return translated

+ 72 - 0
internal/sub/remark_vars_test.go

@@ -102,6 +102,41 @@ func TestExpandRemarkVars_EdgeCases(t *testing.T) {
 	}
 }
 
+// defaultRemarkTemplate mirrors the panel's shipped remark template, the one an
+// inbound with no remark used to render with a leading hyphen.
+const defaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
+
+func TestExpandRemarkVars_DropsHyphenBetweenEmptyTokens(t *testing.T) {
+	cases := []struct {
+		name    string
+		tmpl    string
+		inbound string
+		email   string
+		want    string
+	}{
+		{name: "both values", tmpl: "{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "Germany-john"},
+		{name: "empty inbound", tmpl: "{{INBOUND}}-{{EMAIL}}", email: "john", want: "john"},
+		{name: "empty email", tmpl: "{{INBOUND}} - {{EMAIL}}", inbound: "Germany", want: "Germany"},
+		{name: "literal leading hyphen", tmpl: "-{{EMAIL}}", email: "john", want: "-john"},
+		{name: "literal leading hyphen before an empty token", tmpl: "-{{INBOUND}}-{{EMAIL}}", email: "john", want: "-john"},
+		{name: "empty var between two values keeps one separator", tmpl: "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}", email: "john", want: "john-john"},
+		{name: "emoji decoration before an empty token", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", email: "john", want: "🌐john"},
+		{name: "literal word before an empty token", tmpl: "Sub {{INBOUND}}-{{EMAIL}}", email: "john", want: "Sub john"},
+		{name: "decoration kept when both tokens resolve", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "🌐Germany-john"},
+		{name: "default template, empty inbound", tmpl: defaultRemarkTemplate, email: "john", want: "john"},
+		{name: "default template, empty email", tmpl: defaultRemarkTemplate, inbound: "Germany", want: "Germany"},
+	}
+
+	for _, tt := range cases {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx := expandCtx(model.Client{Email: tt.email}, xray.ClientTraffic{Enable: true}, &model.Inbound{Remark: tt.inbound})
+			if got := expandRemarkVars(tt.tmpl, ctx); got != tt.want {
+				t.Errorf("expandRemarkVars(%q) = %q, want %q", tt.tmpl, got, tt.want)
+			}
+		})
+	}
+}
+
 // An unlimited client drops the quota/expiry segments whole — decoration and the
 // "|" separator included — instead of printing "📊∞|⏳∞D".
 func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
@@ -652,3 +687,40 @@ func TestEmailOnFirstLinkOnly(t *testing.T) {
 		t.Fatalf("second link should still carry the inbound name: %q", second)
 	}
 }
+
+func TestIdentityOnAllLinks(t *testing.T) {
+	const template = "{{INBOUND}}-{{EMAIL}}|{{USERNAME}}|📊{{TRAFFIC_LEFT}}|{{STATUS_EMOJI}}"
+	inbound := &model.Inbound{
+		Remark: "DE",
+		ClientStats: []xray.ClientTraffic{{
+			Email:  "alice@x",
+			Enable: true,
+			Total:  100 * gb,
+			Up:     20 * gb,
+		}},
+	}
+	tests := []struct {
+		name       string
+		enabled    bool
+		wantSecond string
+	}{
+		{name: "disabled", wantSecond: "DE"},
+		{name: "enabled", enabled: true, wantSecond: "DE-alice@x|alice@x"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := &SubService{
+				remarkTemplate:         template,
+				subscriptionBody:       true,
+				showIdentityOnAllLinks: tt.enabled,
+			}
+			client := model.Client{Email: "alice@x"}
+			if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != "DE-alice@x|alice@x|📊80.00GB|✅" {
+				t.Fatalf("first link = %q", got)
+			}
+			if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != tt.wantSecond {
+				t.Fatalf("second link = %q, want %q", got, tt.wantSecond)
+			}
+		})
+	}
+}

+ 17 - 9
internal/sub/service.go

@@ -40,9 +40,10 @@ type SubService struct {
 	// usageShown tracks, per client email, whether the info part of the template
 	// has already been emitted this request, so it appears on the first body
 	// link only. Per-request state; reset in PrepareForRequest.
-	usageShown     map[string]bool
-	inboundService service.InboundService
-	settingService service.SettingService
+	usageShown             map[string]bool
+	showIdentityOnAllLinks bool
+	inboundService         service.InboundService
+	settingService         service.SettingService
 	// nodesByID is populated per request from the Node table so
 	// resolveInboundAddress can return the node's address for any
 	// inbound whose NodeID is set. Keeps the per-link host derivation
@@ -197,6 +198,10 @@ func (s *SubService) loadRemarkSettings() {
 	if err != nil {
 		s.datepicker = "gregorian"
 	}
+	s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks()
+	if err != nil {
+		s.showIdentityOnAllLinks = false
+	}
 }
 
 func (s *SubService) configuredPublicHost() string {
@@ -1625,12 +1630,6 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
 	}
 	if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
 		tlsSettings["fingerprint"] = fp
-		settings, _ := tlsSettings["settings"].(map[string]any)
-		if settings == nil {
-			settings = map[string]any{}
-			tlsSettings["settings"] = settings
-		}
-		settings["fingerprint"] = fp
 	}
 	if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
 		tlsSettings["alpn"] = alpn
@@ -2018,6 +2017,15 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
 			}
 		}
 	}
+	// Older clients still read the pre-#6258 names from the subscription
+	// extra JSON. Emit aliases after lifting legacy inputs so both old and
+	// new clients can consume the same link.
+	if v, ok := extra["sessionIDPlacement"].(string); ok && len(v) > 0 {
+		extra["sessionPlacement"] = v
+	}
+	if v, ok := extra["sessionIDKey"].(string); ok && len(v) > 0 {
+		extra["sessionKey"] = v
+	}
 
 	for _, field := range []string{"uplinkChunkSize"} {
 		if v, ok := nonZeroShareValue(xhttp[field]); ok {

+ 50 - 0
internal/sub/service_test.go

@@ -335,6 +335,8 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
 		"mode":                 "packet-up",
 		"xPaddingBytes":        "100-1000",
 		"uplinkHTTPMethod":     "GET",
+		"sessionIDPlacement":   "header",
+		"sessionIDKey":         "X-Session",
 		"uplinkChunkSize":      float64(4096),
 		"noGRPCHeader":         true,
 		"scMinPostsIntervalMs": "20-40",
@@ -375,6 +377,16 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
 	if extra["mode"] != "packet-up" {
 		t.Fatalf("extra[mode] = %#v, want packet-up", extra["mode"])
 	}
+	for key, want := range map[string]string{
+		"sessionIDPlacement": "header",
+		"sessionIDKey":       "X-Session",
+		"sessionPlacement":   "header",
+		"sessionKey":         "X-Session",
+	} {
+		if extra[key] != want {
+			t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra)
+		}
+	}
 
 	headers, ok := extra["headers"].(map[string]any)
 	if !ok {
@@ -388,6 +400,24 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
 	}
 }
 
+func TestBuildXhttpExtra_LegacySessionFieldsEmitBothNames(t *testing.T) {
+	extra := buildXhttpExtra(map[string]any{
+		"sessionPlacement": "query",
+		"sessionKey":       "sess",
+	})
+
+	for key, want := range map[string]string{
+		"sessionIDPlacement": "query",
+		"sessionIDKey":       "sess",
+		"sessionPlacement":   "query",
+		"sessionKey":         "sess",
+	} {
+		if extra[key] != want {
+			t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra)
+		}
+	}
+}
+
 func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) {
 	extra := buildXhttpExtra(map[string]any{
 		"uplinkHTTPMethod": "",
@@ -775,6 +805,26 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
 	}
 }
 
+func TestApplyExternalProxyTLSToStream_FingerprintNotDuplicated(t *testing.T) {
+	stream := map[string]any{
+		"security":    "tls",
+		"tlsSettings": map[string]any{},
+	}
+	ep := map[string]any{"dest": "proxy.example.com", "fingerprint": "chrome"}
+
+	applyExternalProxyTLSToStream(ep, stream, "tls")
+
+	ts, _ := stream["tlsSettings"].(map[string]any)
+	if ts["fingerprint"] != "chrome" {
+		t.Fatalf("tlsSettings.fingerprint = %v, want %q", ts["fingerprint"], "chrome")
+	}
+	if settings, ok := ts["settings"].(map[string]any); ok {
+		if got, dup := settings["fingerprint"]; dup {
+			t.Fatalf("fingerprint must not be duplicated into tlsSettings.settings, got %v", got)
+		}
+	}
+}
+
 func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
 	params := map[string]string{"security": "tls"}
 	ep := map[string]any{

+ 44 - 11
internal/web/controller/client.go

@@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.GET("/list", a.list)
 	g.GET("/list/paged", a.listPaged)
 	g.GET("/get/:email", a.get)
+	g.GET("/get/tgId/:tgId", a.getByTgId)
 	g.GET("/traffic/:email", a.getTrafficByEmail)
 	g.GET("/subLinks/:subId", a.getSubLinks)
 	g.GET("/links/:email", a.getClientLinks)
@@ -105,6 +106,32 @@ func (a *ClientController) listPaged(c *gin.Context) {
 	jsonObj(c, resp, nil)
 }
 
+func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, error) {
+	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
+	if err != nil {
+		return nil, err
+	}
+	externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		return nil, err
+	}
+	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
+	if err != nil {
+		return nil, err
+	}
+	rec.Flow = flow
+	var usedTraffic int64
+	if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil {
+		usedTraffic = t.Up + t.Down
+	}
+	return gin.H{
+		"client":        rec,
+		"inboundIds":    inboundIds,
+		"externalLinks": externalLinks,
+		"usedTraffic":   usedTraffic,
+	}, nil
+}
+
 func (a *ClientController) get(c *gin.Context) {
 	email := c.Param("email")
 	rec, err := a.clientService.GetRecordByEmail(nil, email)
@@ -112,30 +139,36 @@ func (a *ClientController) get(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 	}
-	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
+	payload, err := a.buildClientPayload(rec)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 	}
-	externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
+	jsonObj(c, payload, nil)
+}
+
+func (a *ClientController) getByTgId(c *gin.Context) {
+	tgIdStr := c.Param("tgId")
+	tgId, err := strconv.ParseInt(tgIdStr, 10, 64)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 	}
-	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
+	records, err := a.clientService.GetRecordsByTgID(tgId)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 	}
-	rec.Flow = flow
-	// Consumed bytes (up+down, including cross-node global overlay) so API
-	// consumers can pair usage with the client's totalGB quota (#4973).
-	// Best-effort: a traffic lookup failure must not break the client fetch.
-	var usedTraffic int64
-	if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
-		usedTraffic = t.Up + t.Down
+	results := make([]gin.H, 0, len(records))
+	for _, rec := range records {
+		payload, err := a.buildClientPayload(rec)
+		if err != nil {
+			jsonMsg(c, I18nWeb(c, "get"), err)
+			return
+		}
+		results = append(results, payload)
 	}
-	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
+	jsonObj(c, results, nil)
 }
 
 func (a *ClientController) create(c *gin.Context) {

+ 6 - 5
internal/web/entity/entity.go

@@ -28,11 +28,12 @@ type AllSetting struct {
 	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
 	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`
 
-	PageSize       int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
-	ExpireDiff     int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
-	TrafficDiff    int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
-	RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
-	Datepicker     string `json:"datepicker" form:"datepicker"`
+	PageSize                  int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
+	ExpireDiff                int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
+	TrafficDiff               int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
+	RemarkTemplate            string `json:"remarkTemplate" form:"remarkTemplate"`
+	SubShowIdentityOnAllLinks bool   `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
+	Datepicker                string `json:"datepicker" form:"datepicker"`
 
 	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`
 	TgBotToken      string `json:"tgBotToken" form:"tgBotToken"`

+ 24 - 3
internal/web/job/periodic_traffic_reset_job.go

@@ -1,6 +1,8 @@
 package job
 
 import (
+	"time"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
@@ -13,13 +15,23 @@ type PeriodicTrafficResetJob struct {
 	inboundService service.InboundService
 	clientService  service.ClientService
 	period         Period
+	location       *time.Location
 }
 
 // NewPeriodicTrafficResetJob creates a new periodic traffic reset job for the specified period.
-func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
+func NewPeriodicTrafficResetJob(period Period, location *time.Location) *PeriodicTrafficResetJob {
 	return &PeriodicTrafficResetJob{
-		period: period,
+		period:   period,
+		location: location,
+	}
+}
+
+func monthlyResetDue(resetDay int, now time.Time) bool {
+	if resetDay < 1 {
+		resetDay = 1
 	}
+	lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
+	return now.Day() == min(resetDay, lastDay)
 }
 
 // Run resets traffic statistics for all inbounds that match the configured reset period.
@@ -30,13 +42,22 @@ func (j *PeriodicTrafficResetJob) Run() {
 		return
 	}
 
+	if j.period == "monthly" {
+		now := time.Now().In(j.location)
+		due := inbounds[:0]
+		for _, inbound := range inbounds {
+			if monthlyResetDue(inbound.TrafficResetDay, now) {
+				due = append(due, inbound)
+			}
+		}
+		inbounds = due
+	}
 	if len(inbounds) == 0 {
 		return
 	}
 	logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
 
 	resetCount := 0
-
 	for _, inbound := range inbounds {
 		resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
 		if resetInboundErr != nil {

+ 31 - 0
internal/web/job/periodic_traffic_reset_job_test.go

@@ -0,0 +1,31 @@
+package job
+
+import (
+	"testing"
+	"time"
+)
+
+func TestMonthlyResetDue(t *testing.T) {
+	cases := []struct {
+		name     string
+		resetDay int
+		now      time.Time
+		want     bool
+	}{
+		{"legacy default on first", 0, time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), true},
+		{"configured day", 15, time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC), true},
+		{"before configured day", 15, time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC), false},
+		{"month end", 31, time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), true},
+		{"short month fallback", 31, time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC), true},
+		{"leap year fallback", 31, time.Date(2028, time.February, 29, 0, 0, 0, 0, time.UTC), true},
+		{"not before short month end", 31, time.Date(2028, time.February, 28, 0, 0, 0, 0, time.UTC), false},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := monthlyResetDue(tc.resetDay, tc.now); got != tc.want {
+				t.Fatalf("monthlyResetDue(%d, %s) = %v, want %v", tc.resetDay, tc.now.Format(time.DateOnly), got, tc.want)
+			}
+		})
+	}
+}

+ 3 - 0
internal/web/runtime/remote.go

@@ -770,6 +770,9 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
 	if ib.TrafficReset != "" {
 		v.Set("trafficReset", ib.TrafficReset)
 	}
+	if ib.TrafficResetDay > 0 {
+		v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
+	}
 	return v
 }
 

+ 6 - 3
internal/web/runtime/remote_test.go

@@ -252,9 +252,12 @@ func TestIsNonEmptySlice(t *testing.T) {
 }
 
 func TestWireInboundTrafficReset(t *testing.T) {
-	with := wireInbound(&model.Inbound{TrafficReset: "daily"}, 0)
-	if got := with.Get("trafficReset"); got != "daily" {
-		t.Fatalf("trafficReset = %q, want daily", got)
+	with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
+	if got := with.Get("trafficReset"); got != "monthly" {
+		t.Fatalf("trafficReset = %q, want monthly", got)
+	}
+	if got := with.Get("trafficResetDay"); got != "15" {
+		t.Fatalf("trafficResetDay = %q, want 15", got)
 	}
 	// Empty TrafficReset must be omitted entirely, not sent as an empty field.
 	without := wireInbound(&model.Inbound{}, 0)

+ 10 - 0
internal/web/service/client_lookup.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	"encoding/json"
+	"errors"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -103,6 +104,15 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
 	return ids, nil
 }
 
+func (s *ClientService) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
+	if tgId <= 0 {
+		return nil, errors.New("tg_id must be a positive integer")
+	}
+	var rows []*model.ClientRecord
+	err := database.GetDB().Where("tg_id = ?", tgId).Find(&rows).Error
+	return rows, err
+}
+
 func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
 	row := &model.ClientRecord{}
 	if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {

+ 80 - 0
internal/web/service/client_lookup_test.go

@@ -0,0 +1,80 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestGetRecordsByTgID(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	db := database.GetDB()
+
+	records := []model.ClientRecord{
+		{Email: "alice@x", TgID: 100, SubID: "sa"},
+		{Email: "bob@x", TgID: 100, SubID: "sb"},
+		{Email: "carol@x", TgID: 200, SubID: "sc"},
+		{Email: "dave@x", TgID: 0, SubID: "sd"},
+	}
+	for _, r := range records {
+		if err := db.Create(&r).Error; err != nil {
+			t.Fatalf("create record %q: %v", r.Email, err)
+		}
+	}
+
+	t.Run("multiple clients share tgId", func(t *testing.T) {
+		got, err := svc.GetRecordsByTgID(100)
+		if err != nil {
+			t.Fatalf("GetRecordsByTgID(100): %v", err)
+		}
+		if len(got) != 2 {
+			t.Fatalf("expected 2 records, got %d", len(got))
+		}
+		emails := make(map[string]bool)
+		for _, r := range got {
+			emails[r.Email] = true
+		}
+		if !emails["alice@x"] || !emails["bob@x"] {
+			t.Fatalf("expected alice@x and bob@x, got %v", got)
+		}
+	})
+
+	t.Run("single client by tgId", func(t *testing.T) {
+		got, err := svc.GetRecordsByTgID(200)
+		if err != nil {
+			t.Fatalf("GetRecordsByTgID(200): %v", err)
+		}
+		if len(got) != 1 {
+			t.Fatalf("expected 1 record, got %d", len(got))
+		}
+		if got[0].Email != "carol@x" {
+			t.Fatalf("expected carol@x, got %s", got[0].Email)
+		}
+	})
+
+	t.Run("tgId zero rejected as sentinel", func(t *testing.T) {
+		_, err := svc.GetRecordsByTgID(0)
+		if err == nil {
+			t.Fatal("expected error for tgId=0")
+		}
+	})
+
+	t.Run("negative tgId rejected", func(t *testing.T) {
+		_, err := svc.GetRecordsByTgID(-5)
+		if err == nil {
+			t.Fatal("expected error for tgId=-5")
+		}
+	})
+
+	t.Run("nonexistent tgId returns empty", func(t *testing.T) {
+		got, err := svc.GetRecordsByTgID(999)
+		if err != nil {
+			t.Fatalf("GetRecordsByTgID(999): %v", err)
+		}
+		if len(got) != 0 {
+			t.Fatalf("expected 0 records, got %d", len(got))
+		}
+	})
+}

+ 10 - 0
internal/web/service/inbound.go

@@ -34,6 +34,13 @@ type InboundService struct {
 	fallbackService FallbackService
 }
 
+func normalizeTrafficResetDay(day int) int {
+	if day < 1 {
+		return 1
+	}
+	return min(day, 31)
+}
+
 func normalizeInboundShareAddrStrategy(strategy string) string {
 	strategy = strings.TrimSpace(strategy)
 	switch strategy {
@@ -905,6 +912,7 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
 // Returns the created inbound, whether Xray needs restart, and any error.
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 	inbound.Id = 0
+	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
@@ -1331,6 +1339,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 }
 
 func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
+	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
@@ -1460,6 +1469,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		oldInbound.Enable = inbound.Enable
 		oldInbound.ExpiryTime = inbound.ExpiryTime
 		oldInbound.TrafficReset = inbound.TrafficReset
+		oldInbound.TrafficResetDay = inbound.TrafficResetDay
 		oldInbound.Listen = inbound.Listen
 		oldInbound.Port = inbound.Port
 		oldInbound.Protocol = inbound.Protocol

+ 9 - 2
internal/web/service/inbound_migration.go

@@ -19,11 +19,18 @@ import (
 func (s *InboundService) MigrationRemoveOrphanedTraffics() {
 	db := database.GetDB()
 	query := fmt.Sprintf(
-		"DELETE FROM client_traffics WHERE email NOT IN (SELECT %s %s)",
+		"DELETE FROM client_traffics WHERE email NOT IN (SELECT email FROM clients) AND email NOT IN (SELECT %s %s)",
 		database.JSONFieldText("client.value", "email"),
 		database.JSONClientsFromInbound(),
 	)
-	db.Exec(query)
+	result := db.Exec(query)
+	if result.Error != nil {
+		logger.Warning("MigrationRemoveOrphanedTraffics failed:", result.Error)
+		return
+	}
+	if result.RowsAffected > 0 {
+		logger.Infof("MigrationRemoveOrphanedTraffics: removed %d orphaned client_traffics row(s)", result.RowsAffected)
+	}
 }
 
 func (s *InboundService) MigrationRequirements() {

+ 59 - 0
internal/web/service/inbound_migration_test.go

@@ -129,6 +129,65 @@ func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
 	}
 }
 
+func TestMigrationRemoveOrphanedTraffics(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	clientSvc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const attachedEmail = "[email protected]"
+	attachedClient := model.Client{Email: attachedEmail, ID: "11111111-1111-1111-1111-111111111111", SubID: attachedEmail, Enable: true}
+	attachedIb := mkInbound(t, 30003, model.VLESS, clientsSettings(t, []model.Client{attachedClient}))
+	if err := clientSvc.SyncInbound(nil, attachedIb.Id, []model.Client{attachedClient}); err != nil {
+		t.Fatalf("seed attached client: %v", err)
+	}
+	mkTraffic(t, attachedIb.Id, attachedEmail, 0, 0, 0, 0, true)
+
+	const detachedEmail = "[email protected]"
+	detachedClient := model.Client{Email: detachedEmail, ID: "22222222-2222-2222-2222-222222222222", SubID: detachedEmail, Enable: true}
+	detachedIb := mkInbound(t, 30004, model.VLESS, clientsSettings(t, []model.Client{detachedClient}))
+	if err := clientSvc.SyncInbound(nil, detachedIb.Id, []model.Client{detachedClient}); err != nil {
+		t.Fatalf("seed detached client: %v", err)
+	}
+	mkTraffic(t, detachedIb.Id, detachedEmail, 123, 456, 0, 0, true)
+	detachedRec := lookupClientRecord(t, detachedEmail)
+	if _, err := clientSvc.Detach(inboundSvc, detachedRec.Id, []int{detachedIb.Id}); err != nil {
+		t.Fatalf("Detach: %v", err)
+	}
+
+	const jsonOnlyEmail = "[email protected]"
+	jsonOnlyClient := model.Client{Email: jsonOnlyEmail, ID: "33333333-3333-3333-3333-333333333333", SubID: jsonOnlyEmail, Enable: true}
+	jsonOnlyIb := mkInbound(t, 30005, model.VLESS, clientsSettings(t, []model.Client{jsonOnlyClient}))
+	mkTraffic(t, jsonOnlyIb.Id, jsonOnlyEmail, 0, 0, 0, 0, true)
+
+	const trulyOrphanedEmail = "[email protected]"
+	mkTraffic(t, attachedIb.Id, trulyOrphanedEmail, 0, 0, 0, 0, true)
+
+	inboundSvc.MigrationRemoveOrphanedTraffics()
+
+	cases := []struct {
+		name  string
+		email string
+		want  int64
+	}{
+		{"attached, in clients table and JSON", attachedEmail, 1},
+		{"detached-but-alive, in clients table only", detachedEmail, 1},
+		{"seeder-skipped-but-live, in JSON only", jsonOnlyEmail, 1},
+		{"truly orphaned, in neither", trulyOrphanedEmail, 0},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			var got int64
+			if err := db.Model(xray.ClientTraffic{}).Where("email = ?", c.email).Count(&got).Error; err != nil {
+				t.Fatalf("count client_traffics for %s: %v", c.email, err)
+			}
+			if got != c.want {
+				t.Errorf("client_traffics count for %s: got %d, want %d", c.email, got, c.want)
+			}
+		})
+	}
+}
+
 func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
 	setupConflictDB(t)
 	db := database.GetDB()

+ 5 - 1
internal/web/service/inbound_node.go

@@ -311,7 +311,8 @@ func adoptedWireChanged(c, snapIb *model.Inbound, adoptedSettings string) bool {
 		c.ExpiryTime != snapIb.ExpiryTime ||
 		c.StreamSettings != snapIb.StreamSettings ||
 		c.Sniffing != snapIb.Sniffing ||
-		c.TrafficReset != snapIb.TrafficReset
+		c.TrafficReset != snapIb.TrafficReset ||
+		c.TrafficResetDay != normalizeTrafficResetDay(snapIb.TrafficResetDay)
 }
 
 // adoptedWireInbound is the central inbound as it reads after adopting the
@@ -330,6 +331,7 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
 	a.StreamSettings = snapIb.StreamSettings
 	a.Sniffing = snapIb.Sniffing
 	a.TrafficReset = snapIb.TrafficReset
+	a.TrafficResetDay = normalizeTrafficResetDay(snapIb.TrafficResetDay)
 	return &a
 }
 
@@ -561,6 +563,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				StreamSettings:       snapIb.StreamSettings,
 				Sniffing:             snapIb.Sniffing,
 				TrafficReset:         snapIb.TrafficReset,
+				TrafficResetDay:      normalizeTrafficResetDay(snapIb.TrafficResetDay),
 				LastTrafficResetTime: snapIb.LastTrafficResetTime,
 				Enable:               snapIb.Enable,
 				Remark:               snapIb.Remark,
@@ -616,6 +619,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			updates["stream_settings"] = snapIb.StreamSettings
 			updates["sniffing"] = snapIb.Sniffing
 			updates["traffic_reset"] = snapIb.TrafficReset
+			updates["traffic_reset_day"] = normalizeTrafficResetDay(snapIb.TrafficResetDay)
 			updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
 			if adoptedWireChanged(c, snapIb, adoptedSettings) {
 				adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(c, snapIb, adoptedSettings))

+ 18 - 0
internal/web/service/inbound_traffic_reset_test.go

@@ -0,0 +1,18 @@
+package service
+
+import "testing"
+
+func TestNormalizeTrafficResetDay(t *testing.T) {
+	tests := map[int]int{
+		0:  1,
+		1:  1,
+		15: 15,
+		31: 31,
+		32: 31,
+	}
+	for input, want := range tests {
+		if got := normalizeTrafficResetDay(input); got != want {
+			t.Errorf("normalizeTrafficResetDay(%d) = %d, want %d", input, got, want)
+		}
+	}
+}

+ 5 - 0
internal/web/service/setting.go

@@ -66,6 +66,7 @@ var defaultValueMap = map[string]string{
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
 	"remarkTemplate":              DefaultRemarkTemplate,
+	"subShowIdentityOnAllLinks":   "false",
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",
@@ -649,6 +650,10 @@ func (s *SettingService) GetRemarkTemplate() (string, error) {
 	return s.getString("remarkTemplate")
 }
 
+func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
+	return s.getBool("subShowIdentityOnAllLinks")
+}
+
 func (s *SettingService) GetSecret() ([]byte, error) {
 	secret, err := s.getString("secret")
 	if secret == defaultValueMap["secret"] {

+ 39 - 0
internal/web/service/setting_sub_identity_test.go

@@ -0,0 +1,39 @@
+package service
+
+import "testing"
+
+func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+
+	if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
+		t.Fatalf("missing setting = %t, %v; want false, nil", got, err)
+	}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if settings.SubShowIdentityOnAllLinks {
+		t.Fatal("GetAllSetting returned true for a missing setting")
+	}
+
+	settings.SubShowIdentityOnAllLinks = true
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+	if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
+		t.Fatalf("persisted setting = %t, %v; want true, nil", got, err)
+	}
+
+	settings, err = s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	settings.SubShowIdentityOnAllLinks = false
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+	if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
+		t.Fatalf("persisted setting = %t, %v; want false, nil", got, err)
+	}
+}

+ 7 - 0
internal/web/translation/ar-EG.json

@@ -451,6 +451,7 @@
       "importInbound": "استيراد إدخال",
       "periodicTrafficResetTitle": "إعادة تعيين حركة المرور",
       "periodicTrafficResetDesc": "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة",
+      "periodicTrafficResetDay": "يوم إعادة التعيين الشهري",
       "lastReset": "آخر إعادة تعيين",
       "periodicTrafficReset": {
         "never": "أبداً",
@@ -636,6 +637,10 @@
         "maxTimeDiff": "أقصى فرق زمن (ms)",
         "minClientVer": "أدنى إصدار للعميل",
         "maxClientVer": "أقصى إصدار للعميل",
+        "minClientVerHint": "تركه فارغًا لا يعني بلا قيود: سيفرض Xray-core الحد الأدنى المدمج في إصدار النواة الذي تشغّله (26.3.27 في الإصدارات الحالية) ويرفض العملاء الذين يبلغون عن إصدار أقدم — بما في ذلك النوى الخارجية مثل Mihomo و sing-box. القيمة 1.0.0 تقبلها، مقابل السماح ببصمات TLS قديمة.",
+        "maxClientVerHint": "تركه فارغًا يعني بلا حد أقصى. إذا عُيّن، يجب ألا يقل عن الحد الأدنى الفعلي — أدنى إصدار للعميل، أو الحد الأدنى المدمج في Xray-core عندما يكون ذلك الحقل فارغًا — وإلا سيُرفض جميع العملاء.",
+        "clientVerInvalid": "يجب أن يتكون إصدار العميل من ثلاثة أرقام كحد أقصى مفصولة بنقاط، كل منها 0-255 (مثل 26.3.27)",
+        "maxClientVerBelowMin": "أقصى إصدار للعميل يجب ألا يقل عن أدنى إصدار للعميل",
         "shortIds": "Short IDs",
         "realityTargetHint": "مطلوب. يجب أن يتضمّن منفذًا (مثل example.com:443). بدون منفذ يرفض Xray-core البدء.",
         "realityTargetRequired": "هدف REALITY مطلوب",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
       "remarkTemplate": "قالب الملاحظة",
       "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
+      "subShowIdentityOnAllLinks": "إظهار الهوية في كل رابط",
+      "subShowIdentityOnAllLinksDesc": "عند التفعيل، يبقى {{EMAIL}} و{{USERNAME}} في ملاحظة كل رابط في محتوى الاشتراك. تظل رموز الاستخدام في الرابط الأول فقط.",
       "validation": {
         "pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
       },

+ 7 - 0
internal/web/translation/en-US.json

@@ -451,6 +451,7 @@
       "importInbound": "Import an Inbound",
       "periodicTrafficResetTitle": "Traffic Reset",
       "periodicTrafficResetDesc": "Automatically reset traffic counter at specified intervals",
+      "periodicTrafficResetDay": "Monthly reset day",
       "lastReset": "Last Reset",
       "periodicTrafficReset": {
         "never": "Never",
@@ -648,6 +649,10 @@
         "maxTimeDiff": "Max Time Diff (ms)",
         "minClientVer": "Min Client Ver",
         "maxClientVer": "Max Client Ver",
+        "minClientVerHint": "Empty does not mean unrestricted: Xray-core then enforces the built-in minimum of the core build you run (26.3.27 in current releases) and rejects clients that report an older version — including third-party cores such as Mihomo and sing-box. Set 1.0.0 to accept them, at the cost of admitting outdated TLS fingerprints.",
+        "maxClientVerHint": "Empty means no upper limit. If set, it must not be lower than the effective minimum — Min Client Ver, or Xray-core's built-in minimum when that field is empty — otherwise every client is rejected.",
+        "clientVerInvalid": "Client version must be up to three dot-separated numbers, each 0-255 (e.g. 26.3.27)",
+        "maxClientVerBelowMin": "Max Client Ver must not be lower than Min Client Ver",
         "shortIds": "Short IDs",
         "realityTargetHint": "Required. Must include a port (e.g. example.com:443). Without a port Xray-core refuses to start.",
         "realityTargetRequired": "REALITY target is required",
@@ -1259,6 +1264,8 @@
       "panelOutboundPh": "Direct connection",
       "remarkTemplate": "Remark Template",
       "remarkTemplateDesc": "When set, this replaces the remark model for every subscription link — write your own format with the variable tokens (use the button to insert them). Leave empty to use the model above.",
+      "subShowIdentityOnAllLinks": "Show identity on every link",
+      "subShowIdentityOnAllLinksDesc": "When enabled, {{EMAIL}} and {{USERNAME}} stay on every subscription-body remark. Usage tokens still appear on the first link only.",
       "datepicker": "Calendar Type",
       "datepickerPlaceholder": "Select date",
       "datepickerDescription": "Scheduled tasks will run based on this calendar.",

+ 7 - 0
internal/web/translation/es-ES.json

@@ -451,6 +451,7 @@
       "importInbound": "Importar un entrante",
       "periodicTrafficResetTitle": "Reset de Tráfico",
       "periodicTrafficResetDesc": "Reiniciar automáticamente el contador de tráfico en intervalos especificados",
+      "periodicTrafficResetDay": "Día de reinicio mensual",
       "lastReset": "Último reinicio",
       "periodicTrafficReset": {
         "never": "Nunca",
@@ -657,6 +658,10 @@
         "maxTimeDiff": "Máx. diferencia de tiempo (ms)",
         "minClientVer": "Mín. versión cliente",
         "maxClientVer": "Máx. versión cliente",
+        "minClientVerHint": "Vacío no significa sin restricción: Xray-core aplica entonces el mínimo integrado de la build del núcleo en uso (26.3.27 en las versiones actuales) y rechaza a los clientes que reportan una versión anterior, incluidos núcleos de terceros como Mihomo y sing-box. Con 1.0.0 se aceptan, a costa de admitir huellas TLS obsoletas.",
+        "maxClientVerHint": "Vacío significa sin límite superior. Si se establece, no debe ser inferior al mínimo efectivo — la versión mínima del cliente o, si ese campo está vacío, el mínimo integrado de Xray-core — o todos los clientes serán rechazados.",
+        "clientVerInvalid": "La versión del cliente debe tener hasta tres números separados por puntos, cada uno 0-255 (p. ej. 26.3.27)",
+        "maxClientVerBelowMin": "La versión máxima del cliente no debe ser inferior a la versión mínima",
         "shortIds": "Short IDs",
         "realityTargetHint": "Obligatorio. Debe incluir un puerto (p. ej. example.com:443). Sin puerto, Xray-core no arranca.",
         "realityTargetRequired": "El destino REALITY es obligatorio",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "Uso de memoria alto (%)",
       "remarkTemplate": "Plantilla de notas",
       "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
+      "subShowIdentityOnAllLinks": "Mostrar identidad en cada enlace",
+      "subShowIdentityOnAllLinksDesc": "Si está activado, {{EMAIL}} y {{USERNAME}} permanecen en la nota de cada enlace del cuerpo de la suscripción. Los tokens de uso siguen solo en el primer enlace.",
       "validation": {
         "pathLeadingSlash": "La ruta debe comenzar con /"
       },

+ 7 - 0
internal/web/translation/fa-IR.json

@@ -451,6 +451,7 @@
       "importInbound": "افزودن یک ورودی",
       "periodicTrafficResetTitle": "بازنشانی ترافیک",
       "periodicTrafficResetDesc": "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص",
+      "periodicTrafficResetDay": "روز بازنشانی ماهانه",
       "lastReset": "آخرین بازنشانی",
       "periodicTrafficReset": {
         "never": "هرگز",
@@ -648,6 +649,10 @@
         "maxTimeDiff": "حداکثر اختلاف زمان (ms)",
         "minClientVer": "حداقل نسخه کلاینت",
         "maxClientVer": "حداکثر نسخه کلاینت",
+        "minClientVerHint": "خالی بودن به معنای بدون محدودیت نیست: در این حالت Xray-core حداقل داخلیِ نسخهٔ هسته‌ای را که اجرا می‌کنید (در نسخه‌های فعلی 26.3.27) اعمال می‌کند و کلاینت‌هایی را که نسخهٔ قدیمی‌تری اعلام می‌کنند رد می‌کند — از جمله هسته‌های شخص ثالث مانند Mihomo و sing-box. مقدار 1.0.0 آن‌ها را می‌پذیرد، به بهای پذیرش اثر انگشت‌های TLS قدیمی.",
+        "maxClientVerHint": "خالی یعنی بدون سقف. در صورت تنظیم، نباید از حداقلِ مؤثر — حداقل نسخه کلاینت، و در صورت خالی بودن آن فیلد، حداقل داخلی Xray-core — کمتر باشد، وگرنه همهٔ کلاینت‌ها رد می‌شوند.",
+        "clientVerInvalid": "نسخهٔ کلاینت باید حداکثر سه عدد جداشده با نقطه باشد، هر یک 0-255 (مثلاً 26.3.27)",
+        "maxClientVerBelowMin": "حداکثر نسخهٔ کلاینت نباید از حداقل نسخهٔ کلاینت کمتر باشد",
         "shortIds": "Short IDها",
         "realityTargetHint": "الزامی است. باید شامل پورت باشد (مثلاً example.com:443). بدون پورت، Xray-core اجرا نمی‌شود.",
         "realityTargetRequired": "هدف REALITY الزامی است",
@@ -1142,6 +1147,8 @@
       "panelOutboundPh": "اتصال مستقیم",
       "remarkTemplate": "قالب ریمارک",
       "remarkTemplateDesc": "اگر پر شود، جای مدلِ ریمارک را برای همه‌ی لینک‌های اشتراک می‌گیرد — فرمت دلخواهت را با توکن‌های متغیر بنویس (از دکمه برای درج استفاده کن). خالی = استفاده از مدلِ بالا.",
+      "subShowIdentityOnAllLinks": "نمایش هویت در همه لینک‌ها",
+      "subShowIdentityOnAllLinksDesc": "در صورت فعال بودن، {{EMAIL}} و {{USERNAME}} در یادداشت هر لینک بدنه اشتراک باقی می‌مانند. توکن‌های مصرف همچنان فقط در لینک اول نمایش داده می‌شوند.",
       "datepicker": "نوع تقویم",
       "datepickerPlaceholder": "انتخاب تاریخ",
       "datepickerDescription": "وظایف برنامه ریزی شده بر اساس این تقویم اجرا می‌شود",

+ 7 - 0
internal/web/translation/id-ID.json

@@ -451,6 +451,7 @@
       "importInbound": "Impor Masuk",
       "periodicTrafficResetTitle": "Reset Trafik Berkala",
       "periodicTrafficResetDesc": "Reset otomatis penghitung trafik pada interval tertentu",
+      "periodicTrafficResetDay": "Hari reset bulanan",
       "lastReset": "Reset Terakhir",
       "periodicTrafficReset": {
         "never": "Tidak Pernah",
@@ -636,6 +637,10 @@
         "maxTimeDiff": "Maks. selisih waktu (ms)",
         "minClientVer": "Min. versi klien",
         "maxClientVer": "Maks. versi klien",
+        "minClientVerHint": "Kosong bukan berarti tanpa batas: Xray-core akan memakai minimum bawaan dari build core yang dijalankan (26.3.27 pada rilis saat ini) dan menolak klien yang melaporkan versi lebih lama — termasuk core pihak ketiga seperti Mihomo dan sing-box. Isi 1.0.0 untuk menerimanya, dengan risiko mengizinkan sidik jari TLS yang usang.",
+        "maxClientVerHint": "Kosong berarti tanpa batas atas. Jika diisi, tidak boleh lebih rendah dari minimum efektif — versi klien minimum, atau minimum bawaan Xray-core saat kolom itu kosong — atau semua klien akan ditolak.",
+        "clientVerInvalid": "Versi klien harus berupa maksimal tiga angka dipisah titik, masing-masing 0-255 (mis. 26.3.27)",
+        "maxClientVerBelowMin": "Versi klien maksimum tidak boleh lebih rendah dari versi klien minimum",
         "shortIds": "Short IDs",
         "realityTargetHint": "Wajib. Harus menyertakan port (mis. example.com:443). Tanpa port, Xray-core menolak untuk mulai.",
         "realityTargetRequired": "Target REALITY wajib diisi",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "remarkTemplate": "Templat Catatan",
       "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
+      "subShowIdentityOnAllLinks": "Tampilkan identitas di setiap tautan",
+      "subShowIdentityOnAllLinksDesc": "Jika diaktifkan, {{EMAIL}} dan {{USERNAME}} tetap ada di catatan setiap tautan isi langganan. Token penggunaan tetap hanya di tautan pertama.",
       "validation": {
         "pathLeadingSlash": "Path harus diawali dengan /"
       },

+ 7 - 0
internal/web/translation/ja-JP.json

@@ -451,6 +451,7 @@
       "importInbound": "インバウンドルールをインポート",
       "periodicTrafficResetTitle": "トラフィックリセット",
       "periodicTrafficResetDesc": "指定された間隔でトラフィックカウンタを自動的にリセット",
+      "periodicTrafficResetDay": "毎月のリセット日",
       "lastReset": "最後のリセット",
       "periodicTrafficReset": {
         "never": "なし",
@@ -657,6 +658,10 @@
         "maxTimeDiff": "最大時間差 (ms)",
         "minClientVer": "最小クライアントバージョン",
         "maxClientVer": "最大クライアントバージョン",
+        "minClientVerHint": "空欄は無制限ではありません。Xray-core は実行中のコアに組み込まれた最低バージョン(現行リリースでは 26.3.27)を適用し、それより古いバージョンを名乗るクライアント(Mihomo や sing-box などのサードパーティコアを含む)を拒否します。1.0.0 を設定すると許可されますが、古い TLS フィンガープリントも受け入れることになります。",
+        "maxClientVerHint": "空欄は上限なしを意味します。設定する場合は実効的な下限(最小クライアントバージョン。その欄が空欄の場合は Xray-core 組み込みの最低バージョン)を下回らないでください。下回るとすべてのクライアントが拒否されます。",
+        "clientVerInvalid": "クライアントバージョンはドット区切りの数値(最大 3 つ、各 0-255)で指定してください(例:26.3.27)",
+        "maxClientVerBelowMin": "最大クライアントバージョンは最小クライアントバージョンを下回れません",
         "shortIds": "Short IDs",
         "realityTargetHint": "必須です。ポートを含める必要があります(例: example.com:443)。ポートがないと Xray-core は起動しません。",
         "realityTargetRequired": "REALITY ターゲットは必須です",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "メモリ使用率が高い (%)",
       "remarkTemplate": "備考テンプレート",
       "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
+      "subShowIdentityOnAllLinks": "すべてのリンクに識別情報を表示",
+      "subShowIdentityOnAllLinksDesc": "有効にすると、{{EMAIL}} と {{USERNAME}} がサブスクリプション本文のすべてのリンク備考に残ります。使用量トークンは引き続き最初のリンクのみです。",
       "validation": {
         "pathLeadingSlash": "パスは / で始まる必要があります"
       },

+ 7 - 0
internal/web/translation/pt-BR.json

@@ -451,6 +451,7 @@
       "importInbound": "Importar um Inbound",
       "periodicTrafficResetTitle": "Reset de Tráfego",
       "periodicTrafficResetDesc": "Reinicia automaticamente o contador de tráfego em intervalos especificados",
+      "periodicTrafficResetDay": "Dia da redefinição mensal",
       "lastReset": "Último Reset",
       "periodicTrafficReset": {
         "never": "Nunca",
@@ -657,6 +658,10 @@
         "maxTimeDiff": "Máx. diferença de tempo (ms)",
         "minClientVer": "Mín. versão cliente",
         "maxClientVer": "Máx. versão cliente",
+        "minClientVerHint": "Vazio não significa sem restrição: o Xray-core aplica o mínimo embutido da build do núcleo em uso (26.3.27 nas versões atuais) e rejeita clientes que reportam uma versão mais antiga — incluindo núcleos de terceiros como Mihomo e sing-box. Definir 1.0.0 os aceita, ao custo de admitir impressões digitais TLS desatualizadas.",
+        "maxClientVerHint": "Vazio significa sem limite superior. Se definido, não deve ser menor que o mínimo efetivo — a versão mínima do cliente ou, se aquele campo estiver vazio, o mínimo embutido do Xray-core — ou todos os clientes serão rejeitados.",
+        "clientVerInvalid": "A versão do cliente deve ter até três números separados por pontos, cada um 0-255 (ex.: 26.3.27)",
+        "maxClientVerBelowMin": "A versão máxima do cliente não deve ser menor que a versão mínima",
         "shortIds": "Short IDs",
         "realityTargetHint": "Obrigatório. Deve incluir uma porta (ex.: example.com:443). Sem porta, o Xray-core não inicia.",
         "realityTargetRequired": "O alvo REALITY é obrigatório",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "Uso de memória alto (%)",
       "remarkTemplate": "Modelo de Observação",
       "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
+      "subShowIdentityOnAllLinks": "Mostrar identidade em todos os links",
+      "subShowIdentityOnAllLinksDesc": "Quando ativado, {{EMAIL}} e {{USERNAME}} permanecem na observação de cada link do corpo da assinatura. Tokens de uso continuam só no primeiro link.",
       "validation": {
         "pathLeadingSlash": "O caminho deve começar com /"
       },

+ 7 - 0
internal/web/translation/ru-RU.json

@@ -451,6 +451,7 @@
       "importInbound": "Импорт подключений",
       "periodicTrafficResetTitle": "Сброс трафика",
       "periodicTrafficResetDesc": "Автоматический сброс счетчика трафика через указанные интервалы",
+      "periodicTrafficResetDay": "День ежемесячного сброса",
       "lastReset": "Последний сброс",
       "periodicTrafficReset": {
         "never": "Никогда",
@@ -657,6 +658,10 @@
         "maxTimeDiff": "Макс. разница во времени (мс)",
         "minClientVer": "Мин. версия клиента",
         "maxClientVer": "Макс. версия клиента",
+        "minClientVerHint": "Пустое поле не означает «без ограничений»: Xray-core применит встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах) и отклонит клиентов, сообщающих более старую версию, — включая сторонние ядра, такие как Mihomo и sing-box. Значение 1.0.0 разрешит их, но допустит устаревшие TLS-отпечатки.",
+        "maxClientVerHint": "Пустое поле — без верхнего предела. Если задано, значение не должно быть ниже действующего минимума — «Мин. версия клиента», а при пустом том поле — встроенного минимума Xray-core, иначе все клиенты будут отклонены.",
+        "clientVerInvalid": "Версия клиента — до трёх чисел через точку, каждое 0-255 (например 26.3.27)",
+        "maxClientVerBelowMin": "Макс. версия клиента не должна быть ниже минимальной версии клиента",
         "shortIds": "Short IDs",
         "realityTargetHint": "Обязательно. Должно содержать порт (например, example.com:443). Без порта Xray-core не запускается.",
         "realityTargetRequired": "Цель REALITY обязательна",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "Превышение порога памяти (%)",
       "remarkTemplate": "Шаблон примечания",
       "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
+      "subShowIdentityOnAllLinks": "Показывать идентификатор на каждой ссылке",
+      "subShowIdentityOnAllLinksDesc": "Если включено, {{EMAIL}} и {{USERNAME}} остаются в примечании каждой ссылки тела подписки. Токены использования по-прежнему только на первой ссылке.",
       "validation": {
         "pathLeadingSlash": "Путь должен начинаться с /"
       },

+ 7 - 0
internal/web/translation/tr-TR.json

@@ -451,6 +451,7 @@
       "importInbound": "Gelen Bağlantı İçe Aktar",
       "periodicTrafficResetTitle": "Trafik Sıfırlama",
       "periodicTrafficResetDesc": "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla",
+      "periodicTrafficResetDay": "Aylık sıfırlama günü",
       "lastReset": "Son Sıfırlama",
       "periodicTrafficReset": {
         "never": "Asla",
@@ -636,6 +637,10 @@
         "maxTimeDiff": "Maks. Zaman Farkı (ms)",
         "minClientVer": "Min. Kullanıcı Sürümü",
         "maxClientVer": "Maks. Kullanıcı Sürümü",
+        "minClientVerHint": "Boş bırakmak sınırsız demek değildir: Xray-core, çalıştırdığınız çekirdek sürümünün yerleşik alt sınırını (güncel sürümlerde 26.3.27) uygular ve daha eski sürüm bildiren istemcileri reddeder — Mihomo ve sing-box gibi üçüncü taraf çekirdekler dahil. 1.0.0 girmek onları kabul eder; bedeli eski TLS parmak izlerine izin vermektir.",
+        "maxClientVerHint": "Boş, üst sınır yok demektir. Ayarlanırsa geçerli alt sınırın — Min. Kullanıcı Sürümü, o alan boşsa Xray-core'un yerleşik alt sınırı — altında olmamalıdır, aksi halde tüm istemciler reddedilir.",
+        "clientVerInvalid": "İstemci sürümü noktayla ayrılmış en fazla üç sayıdan oluşmalıdır, her biri 0-255 (örn. 26.3.27)",
+        "maxClientVerBelowMin": "Maks. istemci sürümü, en düşük istemci sürümünün altında olamaz",
         "shortIds": "Short IDs",
         "realityTargetHint": "Zorunlu. Bir port içermelidir (ör. example.com:443). Port belirtilmezse Xray-core başlamaz.",
         "realityTargetRequired": "REALITY hedefi zorunludur",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "Bellek kullanımı yüksek (%)",
       "remarkTemplate": "Açıklama Şablonu",
       "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
+      "subShowIdentityOnAllLinks": "Kimliği her bağlantıda göster",
+      "subShowIdentityOnAllLinksDesc": "Etkinleştirildiğinde {{EMAIL}} ve {{USERNAME}} abonelik gövdesindeki her bağlantı notunda kalır. Kullanım jetonları yine yalnızca ilk bağlantıda görünür.",
       "validation": {
         "pathLeadingSlash": "Yol / ile başlamalıdır"
       },

+ 7 - 0
internal/web/translation/uk-UA.json

@@ -451,6 +451,7 @@
       "importInbound": "Імпортувати вхідний",
       "periodicTrafficResetTitle": "Скидання трафіку",
       "periodicTrafficResetDesc": "Автоматично скидати лічильник трафіку через певні проміжки часу",
+      "periodicTrafficResetDay": "День щомісячного скидання",
       "lastReset": "Останнє скидання",
       "periodicTrafficReset": {
         "never": "Ніколи",
@@ -636,6 +637,10 @@
         "maxTimeDiff": "Макс. різниця в часі (мс)",
         "minClientVer": "Мін. версія клієнта",
         "maxClientVer": "Макс. версія клієнта",
+        "minClientVerHint": "Порожнє поле не означає «без обмежень»: Xray-core застосує вбудований мінімум використовуваної збірки ядра (26.3.27 у поточних релізах) і відхилятиме клієнтів зі старішою версією — зокрема сторонні ядра, як-от Mihomo та sing-box. Значення 1.0.0 дозволить їх, але допустить застарілі TLS-відбитки.",
+        "maxClientVerHint": "Порожнє поле — без верхньої межі. Якщо задано, значення не має бути нижчим за чинний мінімум — «Мін. версія клієнта», а коли те поле порожнє — вбудований мінімум Xray-core, інакше всіх клієнтів буде відхилено.",
+        "clientVerInvalid": "Версія клієнта — до трьох чисел через крапку, кожне 0-255 (наприклад 26.3.27)",
+        "maxClientVerBelowMin": "Макс. версія клієнта не має бути нижчою за мінімальну версію клієнта",
         "shortIds": "Short IDs",
         "realityTargetHint": "Обов'язково. Має містити порт (напр., example.com:443). Без порту Xray-core не запускається.",
         "realityTargetRequired": "Ціль REALITY обов'язкова",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "Високе використання пам'яті (%)",
       "remarkTemplate": "Шаблон примітки",
       "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
+      "subShowIdentityOnAllLinks": "Показувати ідентичність на кожному посиланні",
+      "subShowIdentityOnAllLinksDesc": "Якщо увімкнено, {{EMAIL}} і {{USERNAME}} залишаються в примітці кожного посилання тіла підписки. Токени використання й надалі лише на першому посиланні.",
       "validation": {
         "pathLeadingSlash": "Шлях має починатися з /"
       },

+ 7 - 0
internal/web/translation/vi-VN.json

@@ -451,6 +451,7 @@
       "importInbound": "Nhập inbound",
       "periodicTrafficResetTitle": "Đặt lại lưu lượng",
       "periodicTrafficResetDesc": "Tự động đặt lại bộ đếm lưu lượng theo khoảng thời gian xác định",
+      "periodicTrafficResetDay": "Ngày đặt lại hàng tháng",
       "lastReset": "Đặt lại lần cuối",
       "periodicTrafficReset": {
         "never": "Không bao giờ",
@@ -657,6 +658,10 @@
         "maxTimeDiff": "Chênh lệch thời gian tối đa (ms)",
         "minClientVer": "Phiên bản client tối thiểu",
         "maxClientVer": "Phiên bản client tối đa",
+        "minClientVerHint": "Để trống không có nghĩa là không giới hạn: Xray-core sẽ áp dụng mức tối thiểu tích hợp của bản core đang chạy (26.3.27 ở các bản phát hành hiện tại) và từ chối các client khai báo phiên bản cũ hơn — bao gồm các core bên thứ ba như Mihomo và sing-box. Đặt 1.0.0 để chấp nhận chúng, đổi lại là cho phép các dấu vân tay TLS lỗi thời.",
+        "maxClientVerHint": "Để trống nghĩa là không có giới hạn trên. Nếu đặt, không được thấp hơn mức tối thiểu đang có hiệu lực — phiên bản client tối thiểu, hoặc mức tối thiểu tích hợp của Xray-core khi ô đó để trống — nếu không mọi client đều bị từ chối.",
+        "clientVerInvalid": "Phiên bản client phải gồm tối đa ba số cách nhau bằng dấu chấm, mỗi số 0-255 (ví dụ 26.3.27)",
+        "maxClientVerBelowMin": "Phiên bản client tối đa không được thấp hơn phiên bản client tối thiểu",
         "shortIds": "Short IDs",
         "realityTargetHint": "Bắt buộc. Phải bao gồm cổng (ví dụ example.com:443). Không có cổng, Xray-core sẽ không khởi động.",
         "realityTargetRequired": "Mục tiêu REALITY là bắt buộc",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
       "remarkTemplate": "Mẫu ghi chú",
       "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
+      "subShowIdentityOnAllLinks": "Hiện danh tính trên mọi liên kết",
+      "subShowIdentityOnAllLinksDesc": "Khi bật, {{EMAIL}} và {{USERNAME}} vẫn có trong ghi chú mọi liên kết phần thân đăng ký. Token dung lượng vẫn chỉ ở liên kết đầu tiên.",
       "validation": {
         "pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
       },

+ 42 - 35
internal/web/translation/zh-CN.json

@@ -82,8 +82,8 @@
   "secAlertPanelURI": "面板默认 URI 路径不安全。请配置复杂的 URI 路径。",
   "secAlertSubURI": "订阅默认 URI 路径不安全。请配置复杂的 URI 路径。",
   "secAlertSubJsonURI": "订阅 JSON 默认 URI 路径不安全。请配置复杂的 URI 路径。",
-  "emptyDnsDesc": "未添加DNS服务器。",
-  "emptyFakeDnsDesc": "未添加Fake DNS服务器。",
+  "emptyDnsDesc": "未添加 DNS 服务器。",
+  "emptyFakeDnsDesc": "未添加 Fake DNS 服务器。",
   "emptyBalancersDesc": "未添加负载均衡器。",
   "emptyReverseDesc": "未添加反向代理。",
   "somethingWentWrong": "出了点问题",
@@ -169,7 +169,7 @@
       "xrayStatusRunning": "运行中",
       "xrayStatusStop": "停止",
       "xrayStatusError": "错误",
-      "xrayErrorPopoverTitle": "运行Xray时发生错误",
+      "xrayErrorPopoverTitle": "运行 Xray 时发生错误",
       "operationHours": "系统正常运行时间",
       "systemHistoryTitle": "系统历史",
       "historyTitleCpu": "CPU 使用率",
@@ -215,8 +215,8 @@
       "systemLoad": "系统负载",
       "systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载",
       "connectionCount": "连接数",
-      "ipAddresses": "IP地址",
-      "toggleIpVisibility": "切换IP可见性",
+      "ipAddresses": "IP 地址",
+      "toggleIpVisibility": "切换 IP 可见性",
       "overallSpeed": "整体速度",
       "upload": "上传",
       "download": "下载",
@@ -224,8 +224,8 @@
       "sent": "已发送",
       "received": "已接收",
       "documentation": "文档",
-      "xraySwitchVersionDialog": "您确定要更改Xray版本吗?",
-      "xraySwitchVersionDialogDesc": "这将把Xray版本更改为#version#。",
+      "xraySwitchVersionDialog": "您确定要更改 Xray 版本吗?",
+      "xraySwitchVersionDialogDesc": "这将把 Xray 版本更改为 #version#。",
       "xraySwitchVersionPopover": "Xray 更新成功",
       "panelUpdateDialog": "您确定要更新面板吗?",
       "panelUpdateDialogDesc": "这将把 3X-UI 更新到 #version# 并重启面板服务。",
@@ -441,7 +441,7 @@
         "streamHelp": "Xray stream 块包装:",
         "jsonErrorPrefix": "高级 JSON"
       },
-      "telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
+      "telegramDesc": "请提供 Telegram 聊天 ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
       "subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。",
       "subSortIndex": "订阅排序",
       "same": "相同",
@@ -451,6 +451,7 @@
       "importInbound": "导入入站规则",
       "periodicTrafficResetTitle": "流量重置",
       "periodicTrafficResetDesc": "按指定间隔自动重置流量计数器",
+      "periodicTrafficResetDay": "每月重置日",
       "lastReset": "上次重置",
       "periodicTrafficReset": {
         "never": "从不",
@@ -479,9 +480,9 @@
         "resetInboundClientTrafficSuccess": "流量已重置",
         "resetInboundTrafficSuccess": "入站流量已重置",
         "trafficGetError": "获取流量数据时出错",
-        "getNewX25519CertError": "获取X25519证书时出错。",
-        "getNewmldsa65Error": "获取mldsa65证书时出错。",
-        "getNewVlessEncError": "获取VlessEnc证书时出错。",
+        "getNewX25519CertError": "获取 X25519 证书时出错。",
+        "getNewmldsa65Error": "获取 mldsa65 证书时出错。",
+        "getNewVlessEncError": "获取 VlessEnc 证书时出错。",
         "scanRealityTargetError": "扫描 REALITY 目标失败。",
         "scanRealityTargetFeasible": "目标可用 — 已填入目标和 SNI。",
         "scanRealityTargetNotFeasible": "目标可达,但不适用于 REALITY。",
@@ -656,6 +657,10 @@
         "maxTimeDiff": "最大时间差 (ms)",
         "minClientVer": "最小客户端版本",
         "maxClientVer": "最大客户端版本",
+        "minClientVerHint": "留空不等于不限制:Xray-core 会改用所运行内核版本的内置最低值(当前版本为 26.3.27),拒绝自报版本更低的客户端——包括 Mihomo、sing-box 等第三方内核。填 1.0.0 可放行它们,代价是允许过时的 TLS 指纹。",
+        "maxClientVerHint": "留空表示无上限。若填写,不得低于实际生效的下限——最小客户端版本,该字段留空时则为 Xray-core 的内置最低值——否则所有客户端都会被拒绝。",
+        "clientVerInvalid": "客户端版本须为最多三段以点分隔的数字,每段 0-255(例如 26.3.27)",
+        "maxClientVerBelowMin": "最大客户端版本不得低于最小客户端版本",
         "shortIds": "Short IDs",
         "realityTargetHint": "必填。必须包含端口(例如 example.com:443)。没有端口时 Xray-core 将无法启动。",
         "realityTargetRequired": "REALITY 目标为必填项",
@@ -810,7 +815,7 @@
       "online": "在线",
       "email": "邮箱",
       "emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
-      "subIdInvalidChars": "订阅ID不能包含空格、'/'、'\\' 或控制字符",
+      "subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
       "group": "分组",
       "groupDesc": "用于对相关客户端进行分桶的逻辑标签(如团队、客户、地区)。可从工具栏筛选。",
       "groupPlaceholder": "如 customer-a",
@@ -1011,7 +1016,7 @@
       "regenerate": "重新生成令牌",
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
       "allowPrivateAddress": "允许私有地址",
-      "allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
+      "allowPrivateAddressHint": "仅对私有网络或 VPN 上的节点启用。",
       "outboundTag": "连接出站",
       "outboundTagHint": "通过选定的 Xray 出站路由此节点的面板 API 流量。系统会自动将回环桥接入站添加到运行配置并实时应用。留空表示直接连接。",
       "outboundTagPlaceholder": "直接连接",
@@ -1206,7 +1211,7 @@
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
       "subClashUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上识别 Clash/Mihomo 客户端的 Go RE2 正则表达式。留空则使用默认规则。更改后请重启面板。",
       "subTitle": "订阅标题",
-      "subTitleDesc": "在VPN客户端中显示的标题",
+      "subTitleDesc": "在 VPN 客户端中显示的标题",
       "subSupportUrl": "支持链接",
       "subSupportUrlDesc": "VPN 客户端中显示的技术支持链接",
       "subProfileUrl": "个人资料链接",
@@ -1315,7 +1320,7 @@
       "muxDesc": "在已建立的数据流内传输多个独立的数据流",
       "muxSett": "复用器设置",
       "direct": "直接连接",
-      "directDesc": "直接与特定国家的域或IP范围建立连接",
+      "directDesc": "直接与特定国家的域或 IP 范围建立连接",
       "notifications": "通知",
       "certs": "证书",
       "externalTraffic": "外部流量",
@@ -1329,12 +1334,12 @@
       "security": {
         "admin": "管理员凭据",
         "twoFactor": "双重验证",
-        "twoFactorEnable": "启用2FA",
+        "twoFactorEnable": "启用 2FA",
         "twoFactorEnableDesc": "增加额外的验证层以提高安全性。",
         "twoFactorModalSetTitle": "启用双重认证",
         "twoFactorModalDeleteTitle": "停用双重认证",
         "twoFactorModalSteps": "要设定双重认证,请执行以下步骤:",
-        "twoFactorModalFirstStep": "1. 在认证应用程序中扫描此QR码,或复制QR码附近的令牌并粘贴到应用程序中",
+        "twoFactorModalFirstStep": "1. 在认证应用程序中扫描此 QR 码,或复制 QR 码附近的令牌并粘贴到应用程序中",
         "twoFactorModalSecondStep": "2. 输入应用程序中的验证码",
         "twoFactorModalRemoveStep": "输入应用程序中的验证码以移除双重认证。",
         "twoFactorModalChangeCredentialsTitle": "更改凭据",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "内存使用率高 (%)",
       "remarkTemplate": "备注模板",
       "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
+      "subShowIdentityOnAllLinks": "在每个链接上显示身份",
+      "subShowIdentityOnAllLinksDesc": "启用后,{{EMAIL}} 和 {{USERNAME}} 会保留在每条订阅正文备注中。用量相关变量仍仅出现在第一条链接。",
       "validation": {
         "pathLeadingSlash": "路径必须以 / 开头"
       },
@@ -1456,8 +1463,8 @@
       "restartConfirmTitle": "重启 xray?",
       "restartConfirmContent": "使用已保存的配置重新加载 xray 服务。",
       "stopSuccess": "Xray 已成功停止",
-      "restartError": "重启Xray时发生错误。",
-      "stopError": "停止Xray时发生错误。",
+      "restartError": "重启 Xray 时发生错误。",
+      "stopError": "停止 Xray 时发生错误。",
       "basicTemplate": "基础配置",
       "advancedTemplate": "高级配置",
       "generalConfigs": "常规配置",
@@ -1468,9 +1475,9 @@
       "basicRouting": "基本路由",
       "blockConnectionsConfigsDesc": "这些选项将根据特定的请求国家阻止流量。",
       "directConnectionsConfigsDesc": "直接连接确保特定的流量不会通过其他服务器路由。",
-      "blockips": "阻止IP",
+      "blockips": "阻止 IP",
       "blockdomains": "阻止域名",
-      "directips": "直接IP",
+      "directips": "直接 IP",
       "directdomains": "直接域名",
       "ipv4Routing": "IPv4 路由",
       "ipv4RoutingDesc": "此选项将仅通过 IPv4 路由到目标域",
@@ -1848,28 +1855,28 @@
         "enableDesc": "启用内置 DNS 服务器",
         "tag": "DNS 入站标签",
         "tagDesc": "此标签将在路由规则中可用作入站标签",
-        "clientIp": "客户端IP",
-        "clientIpDesc": "用于在DNS查询期间通知服务器指定的IP位置",
+        "clientIp": "客户端 IP",
+        "clientIpDesc": "用于在 DNS 查询期间通知服务器指定的 IP 位置",
         "disableCache": "禁用缓存",
-        "disableCacheDesc": "禁用DNS缓存",
+        "disableCacheDesc": "禁用 DNS 缓存",
         "disableFallback": "禁用回退",
-        "disableFallbackDesc": "禁用回退DNS查询",
+        "disableFallbackDesc": "禁用回退 DNS 查询",
         "disableFallbackIfMatch": "匹配时禁用回退",
-        "disableFallbackIfMatchDesc": "当DNS服务器的匹配域名列表命中时,禁用回退DNS查询",
+        "disableFallbackIfMatchDesc": "当 DNS 服务器的匹配域名列表命中时,禁用回退 DNS 查询",
         "enableParallelQuery": "启用并行查询",
-        "enableParallelQueryDesc": "启用并行DNS查询到多个服务器以实现更快的解析",
+        "enableParallelQueryDesc": "启用并行 DNS 查询到多个服务器以实现更快的解析",
         "strategy": "查询策略",
         "strategyDesc": "解析域名的总体策略",
         "add": "添加服务器",
         "edit": "编辑服务器",
         "domains": "域名",
         "expectIPs": "预期 IP",
-        "unexpectIPs": "意外IP",
-        "useSystemHosts": "使用系统Hosts",
-        "useSystemHostsDesc": "使用已安装系统的hosts文件",
+        "unexpectIPs": "意外 IP",
+        "useSystemHosts": "使用系统 Hosts",
+        "useSystemHostsDesc": "使用已安装系统的 hosts 文件",
         "serveStale": "提供过期结果",
         "serveStaleDesc": "在后台刷新时返回过期的缓存结果",
-        "serveExpiredTTL": "过期TTL",
+        "serveExpiredTTL": "过期 TTL",
         "serveExpiredTTLDesc": "过期缓存条目的有效期(秒);0 = 永不过期",
         "timeoutMs": "超时 (毫秒)",
         "skipFallback": "跳过回退",
@@ -1880,7 +1887,7 @@
         "hostsDomain": "域名 (例如 domain:example.com)",
         "hostsValues": "IP 或域名 — 输入后按 Enter",
         "usePreset": "使用模板",
-        "dnsPresetTitle": "DNS模板",
+        "dnsPresetTitle": "DNS 模板",
         "dnsPresetFamily": "家庭",
         "clearAll": "删除全部",
         "clearAllTitle": "删除所有 DNS 服务器?",
@@ -2019,7 +2026,7 @@
     "noResult": "❗ 没有结果!",
     "noQuery": "❌ 未找到查询!请再次使用该命令!",
     "wentWrong": "❌ 出了点问题!",
-    "noIpRecord": "❗ 没有IP记录!",
+    "noIpRecord": "❗ 没有 IP 记录!",
     "noInbounds": "❗ 未找到入站!",
     "unlimited": "♾ 无限(重置)",
     "add": "添加",
@@ -2043,8 +2050,8 @@
       "status": "✅ 机器人正常运行!",
       "usage": "❗ 请输入要搜索的文本!",
       "getID": "🆔 您的 ID 为:<code>{{ .ID }}</code>",
-      "helpAdminCommands": "要重新启动 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜索客户电子邮件:\r\n<code>/usage [电子邮件]</code>\r\n\r\n要搜索入站(带有客户统计数据):\r\n<code>/inbound [备注]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
-      "helpClientCommands": "要搜索统计数据,请使用以下命令:\r\n<code>/usage [电子邮件]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
+      "helpAdminCommands": "要重新启动 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜索客户电子邮件:\r\n<code>/usage [电子邮件]</code>\r\n\r\n要搜索入站(带有客户统计数据):\r\n<code>/inbound [备注]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
+      "helpClientCommands": "要搜索统计数据,请使用以下命令:\r\n<code>/usage [电子邮件]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
       "restartUsage": "\r\n\r\n<code>/restart</code>",
       "restartSuccess": "✅ 操作成功!",
       "restartFailed": "❗ 操作错误。\r\n\r\n<code>错误: {{ .Error }}</code>.",

+ 42 - 35
internal/web/translation/zh-TW.json

@@ -82,8 +82,8 @@
   "secAlertPanelURI": "面板預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
   "secAlertSubURI": "訂閱預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
   "secAlertSubJsonURI": "訂閱 JSON 預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
-  "emptyDnsDesc": "未添加DNS伺服器。",
-  "emptyFakeDnsDesc": "未添加Fake DNS伺服器。",
+  "emptyDnsDesc": "未添加 DNS 伺服器。",
+  "emptyFakeDnsDesc": "未添加 Fake DNS 伺服器。",
   "emptyBalancersDesc": "未添加負載平衡器。",
   "emptyReverseDesc": "未添加反向代理。",
   "somethingWentWrong": "發生錯誤",
@@ -169,7 +169,7 @@
       "xrayStatusRunning": "運行中",
       "xrayStatusStop": "停止",
       "xrayStatusError": "錯誤",
-      "xrayErrorPopoverTitle": "執行Xray時發生錯誤",
+      "xrayErrorPopoverTitle": "執行 Xray 時發生錯誤",
       "operationHours": "系統正常執行時間",
       "systemHistoryTitle": "系統歷史",
       "historyTitleCpu": "CPU 使用率",
@@ -215,8 +215,8 @@
       "systemLoad": "系統負載",
       "systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載",
       "connectionCount": "連線數",
-      "ipAddresses": "IP地址",
-      "toggleIpVisibility": "切換IP可見性",
+      "ipAddresses": "IP 地址",
+      "toggleIpVisibility": "切換 IP 可見性",
       "overallSpeed": "整體速度",
       "upload": "上傳",
       "download": "下載",
@@ -224,8 +224,8 @@
       "sent": "已發送",
       "received": "已接收",
       "documentation": "文件",
-      "xraySwitchVersionDialog": "您確定要變更Xray版本嗎?",
-      "xraySwitchVersionDialogDesc": "這將會把Xray版本變更為#version#。",
+      "xraySwitchVersionDialog": "您確定要變更 Xray 版本嗎?",
+      "xraySwitchVersionDialogDesc": "這將會把 Xray 版本變更為 #version#。",
       "xraySwitchVersionPopover": "Xray 更新成功",
       "panelUpdateDialog": "您確定要更新面板嗎?",
       "panelUpdateDialogDesc": "這將把 3X-UI 更新到 #version# 並重新啟動面板服務。",
@@ -441,7 +441,7 @@
         "streamHelp": "Xray stream 區塊包裝:",
         "jsonErrorPrefix": "進階 JSON"
       },
-      "telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
+      "telegramDesc": "請提供 Telegram 聊天 ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
       "subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。",
       "subSortIndex": "訂閱排序",
       "same": "相同",
@@ -451,6 +451,7 @@
       "importInbound": "匯入入站規則",
       "periodicTrafficResetTitle": "流量重置",
       "periodicTrafficResetDesc": "按指定間隔自動重置流量計數器",
+      "periodicTrafficResetDay": "每月重置日",
       "lastReset": "上次重置",
       "periodicTrafficReset": {
         "never": "從不",
@@ -479,9 +480,9 @@
         "resetInboundClientTrafficSuccess": "流量已重置",
         "resetInboundTrafficSuccess": "入站流量已重置",
         "trafficGetError": "取得流量資料時發生錯誤",
-        "getNewX25519CertError": "取得X25519憑證時發生錯誤。",
-        "getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",
-        "getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。",
+        "getNewX25519CertError": "取得 X25519 憑證時發生錯誤。",
+        "getNewmldsa65Error": "取得 mldsa65 憑證時發生錯誤。",
+        "getNewVlessEncError": "取得 VlessEnc 憑證時發生錯誤。",
         "scanRealityTargetError": "掃描 REALITY 目標失敗。",
         "scanRealityTargetFeasible": "目標可用 — 已填入目標與 SNI。",
         "scanRealityTargetNotFeasible": "目標可達,但不適用於 REALITY。",
@@ -636,6 +637,10 @@
         "maxTimeDiff": "最大時間差 (ms)",
         "minClientVer": "最小客戶端版本",
         "maxClientVer": "最大客戶端版本",
+        "minClientVerHint": "留空不等於不限制:Xray-core 會改用所執行核心版本的內建最低值(目前版本為 26.3.27),拒絕自報版本較低的客戶端——包括 Mihomo、sing-box 等第三方核心。填 1.0.0 可放行它們,代價是允許過時的 TLS 指紋。",
+        "maxClientVerHint": "留空表示無上限。若填寫,不得低於實際生效的下限——最小客戶端版本,該欄位留空時則為 Xray-core 的內建最低值——否則所有客戶端都會被拒絕。",
+        "clientVerInvalid": "客戶端版本須為最多三段以點分隔的數字,每段 0-255(例如 26.3.27)",
+        "maxClientVerBelowMin": "最大客戶端版本不得低於最小客戶端版本",
         "shortIds": "Short IDs",
         "realityTargetHint": "必填。必須包含連接埠(例如 example.com:443)。沒有連接埠時 Xray-core 將無法啟動。",
         "realityTargetRequired": "REALITY 目標為必填項",
@@ -810,7 +815,7 @@
       "online": "上線",
       "email": "電子郵件",
       "emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
-      "subIdInvalidChars": "訂閱ID不能包含空格、'/'、'\\' 或控制字元",
+      "subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
       "group": "群組",
       "groupDesc": "用於將相關客戶端歸類的邏輯標籤(如團隊、客戶、地區)。可從工具列篩選。",
       "groupPlaceholder": "如 customer-a",
@@ -1011,7 +1016,7 @@
       "regenerate": "重新產生權杖",
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
       "allowPrivateAddress": "允許私有地址",
-      "allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
+      "allowPrivateAddressHint": "僅對私有網路或 VPN 上的節點啟用。",
       "outboundTag": "連線出站",
       "outboundTagHint": "透過選定的 Xray 出站路由此節點的面板 API 流量。系統會自動將迴環橋接入站加入執行中的設定並即時套用。留空表示直接連線。",
       "outboundTagPlaceholder": "直接連線",
@@ -1206,7 +1211,7 @@
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
       "subClashUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上識別 Clash/Mihomo 用戶端的 Go RE2 正規表示式。留空則使用預設規則。變更後請重新啟動面板。",
       "subTitle": "訂閱標題",
-      "subTitleDesc": "在VPN客戶端中顯示的標題",
+      "subTitleDesc": "在 VPN 客戶端中顯示的標題",
       "subSupportUrl": "支援連結",
       "subSupportUrlDesc": "VPN 用戶端中顯示的技術支援連結",
       "subProfileUrl": "個人資料連結",
@@ -1315,7 +1320,7 @@
       "muxDesc": "在已建立的資料流內傳輸多個獨立的資料流",
       "muxSett": "複用器設定",
       "direct": "直接連線",
-      "directDesc": "直接與特定國家的域或IP範圍建立連線",
+      "directDesc": "直接與特定國家的域或 IP 範圍建立連線",
       "notifications": "通知",
       "certs": "證書",
       "externalTraffic": "外部流量",
@@ -1329,12 +1334,12 @@
       "security": {
         "admin": "管理員憑證",
         "twoFactor": "雙重驗證",
-        "twoFactorEnable": "啟用2FA",
+        "twoFactorEnable": "啟用 2FA",
         "twoFactorEnableDesc": "增加額外的驗證層以提高安全性。",
         "twoFactorModalSetTitle": "啟用雙重認證",
         "twoFactorModalDeleteTitle": "停用雙重認證",
         "twoFactorModalSteps": "要設定雙重認證,請執行以下步驟:",
-        "twoFactorModalFirstStep": "1. 在認證應用程式中掃描此QR碼,或複製QR碼附近的令牌並貼到應用程式中",
+        "twoFactorModalFirstStep": "1. 在認證應用程式中掃描此 QR 碼,或複製 QR 碼附近的令牌並貼到應用程式中",
         "twoFactorModalSecondStep": "2. 輸入應用程式中的驗證碼",
         "twoFactorModalRemoveStep": "輸入應用程式中的驗證碼以移除雙重認證。",
         "twoFactorModalChangeCredentialsTitle": "更改憑證",
@@ -1433,6 +1438,8 @@
       "eventMemoryHigh": "記憶體使用率高 (%)",
       "remarkTemplate": "備註範本",
       "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
+      "subShowIdentityOnAllLinks": "在每個連結上顯示身分",
+      "subShowIdentityOnAllLinksDesc": "啟用後,{{EMAIL}} 與 {{USERNAME}} 會保留在每條訂閱正文備註中。用量相關變數仍僅出現在第一條連結。",
       "validation": {
         "pathLeadingSlash": "路徑必須以 / 開頭"
       },
@@ -1448,8 +1455,8 @@
       "restartConfirmTitle": "重新啟動 xray?",
       "restartConfirmContent": "使用已儲存的設定重新載入 xray 服務。",
       "stopSuccess": "Xray 已成功停止",
-      "restartError": "重新啟動Xray時發生錯誤。",
-      "stopError": "停止Xray時發生錯誤。",
+      "restartError": "重新啟動 Xray 時發生錯誤。",
+      "stopError": "停止 Xray 時發生錯誤。",
       "importRules": "匯入規則",
       "exportRules": "匯出規則",
       "importOutbounds": "匯入出站",
@@ -1468,9 +1475,9 @@
       "basicRouting": "基本路由",
       "blockConnectionsConfigsDesc": "這些選項將根據特定的請求國家阻止流量。",
       "directConnectionsConfigsDesc": "直接連線確保特定的流量不會通過其他伺服器路由。",
-      "blockips": "阻止IP",
+      "blockips": "阻止 IP",
       "blockdomains": "阻止域名",
-      "directips": "直接IP",
+      "directips": "直接 IP",
       "directdomains": "直接域名",
       "ipv4Routing": "IPv4 路由",
       "ipv4RoutingDesc": "此選項將僅通過 IPv4 路由到目標域",
@@ -1848,28 +1855,28 @@
         "enableDesc": "啟用內建 DNS 伺服器",
         "tag": "DNS 入站標籤",
         "tagDesc": "此標籤將在路由規則中可用作入站標籤",
-        "clientIp": "客戶端IP",
-        "clientIpDesc": "用於在DNS查詢期間通知伺服器指定的IP位置",
+        "clientIp": "客戶端 IP",
+        "clientIpDesc": "用於在 DNS 查詢期間通知伺服器指定的 IP 位置",
         "disableCache": "禁用快取",
-        "disableCacheDesc": "禁用DNS快取",
+        "disableCacheDesc": "禁用 DNS 快取",
         "disableFallback": "禁用回退",
-        "disableFallbackDesc": "禁用回退DNS查詢",
+        "disableFallbackDesc": "禁用回退 DNS 查詢",
         "disableFallbackIfMatch": "匹配時禁用回退",
-        "disableFallbackIfMatchDesc": "當DNS伺服器的匹配域名列表命中時,禁用回退DNS查詢",
+        "disableFallbackIfMatchDesc": "當 DNS 伺服器的匹配域名列表命中時,禁用回退 DNS 查詢",
         "enableParallelQuery": "啟用並行查詢",
-        "enableParallelQueryDesc": "啟用並行DNS查詢到多個伺服器以實現更快的解析",
+        "enableParallelQueryDesc": "啟用並行 DNS 查詢到多個伺服器以實現更快的解析",
         "strategy": "查詢策略",
         "strategyDesc": "解析域名的總體策略",
         "add": "新增伺服器",
         "edit": "編輯伺服器",
         "domains": "網域",
         "expectIPs": "預期 IP",
-        "unexpectIPs": "意外IP",
-        "useSystemHosts": "使用系統Hosts",
-        "useSystemHostsDesc": "使用已安裝系統的hosts檔案",
+        "unexpectIPs": "意外 IP",
+        "useSystemHosts": "使用系統 Hosts",
+        "useSystemHostsDesc": "使用已安裝系統的 hosts 檔案",
         "serveStale": "提供過期結果",
         "serveStaleDesc": "在背景重新整理時傳回過期的快取結果",
-        "serveExpiredTTL": "過期TTL",
+        "serveExpiredTTL": "過期 TTL",
         "serveExpiredTTLDesc": "過期快取項目的有效期(秒);0 = 永不過期",
         "timeoutMs": "逾時 (毫秒)",
         "skipFallback": "跳過回退",
@@ -1880,7 +1887,7 @@
         "hostsDomain": "網域 (例如 domain:example.com)",
         "hostsValues": "IP 或網域 — 輸入後按 Enter",
         "usePreset": "使用範本",
-        "dnsPresetTitle": "DNS範本",
+        "dnsPresetTitle": "DNS 範本",
         "dnsPresetFamily": "家庭",
         "clearAll": "全部刪除",
         "clearAllTitle": "刪除所有 DNS 伺服器?",
@@ -2019,7 +2026,7 @@
     "noResult": "❗ 沒有結果!",
     "noQuery": "❌ 未找到查詢!請再次使用該命令!",
     "wentWrong": "❌ 出了點問題!",
-    "noIpRecord": "❗ 沒有IP記錄!",
+    "noIpRecord": "❗ 沒有 IP 記錄!",
     "noInbounds": "❗ 未找到入站!",
     "unlimited": "♾ 無限(重置)",
     "add": "添加",
@@ -2043,8 +2050,8 @@
       "status": "✅ 機器人正常執行!",
       "usage": "❗ 請輸入要搜尋的文字!",
       "getID": "🆔 您的 ID 為:<code>{{ .ID }}</code>",
-      "helpAdminCommands": "要重新啟動 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜尋客戶電子郵件:\r\n<code>/usage [電子郵件]</code>\r\n\r\n要搜尋入站(帶有客戶統計資料):\r\n<code>/inbound [備註]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
-      "helpClientCommands": "要搜尋統計資料,請使用以下命令:\r\n<code>/usage [電子郵件]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
+      "helpAdminCommands": "要重新啟動 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜尋客戶電子郵件:\r\n<code>/usage [電子郵件]</code>\r\n\r\n要搜尋入站(帶有客戶統計資料):\r\n<code>/inbound [備註]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
+      "helpClientCommands": "要搜尋統計資料,請使用以下命令:\r\n<code>/usage [電子郵件]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
       "restartUsage": "\r\n\r\n<code>/restart</code>",
       "restartSuccess": "✅ 操作成功!",
       "restartFailed": "❗ 操作錯誤。\r\n\r\n<code>錯誤: {{ .Error }}</code>.",

+ 7 - 7
internal/web/web.go

@@ -302,7 +302,7 @@ const (
 
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // jobs) which the panel relies on for periodic maintenance and monitoring.
-func (s *Server) startTask(restartXray bool) {
+func (s *Server) startTask(restartXray bool, loc *time.Location) {
 	if restartXray {
 		err := s.xrayService.RestartXray(true)
 		if err != nil {
@@ -344,13 +344,13 @@ func (s *Server) startTask(restartXray bool) {
 
 	// Inbound traffic reset jobs
 	// Run every hour
-	_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
+	_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly", loc))
 	// Run once a day, midnight
-	_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
+	_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily", loc))
 	// Run once a week, midnight between Sat/Sun
-	_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
-	// Run once a month, midnight, first of month
-	_, _ = s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
+	_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly", loc))
+	// Check monthly reset days at midnight
+	_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
 
 	// LDAP sync scheduling
 	if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
@@ -651,7 +651,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		}
 	})
 
-	s.startTask(restartXray)
+	s.startTask(restartXray, loc)
 
 	if startTgBot {
 		isTgbotenabled, err := s.settingService.GetTgbotEnabled()