فهرست منبع

feat(xray/dns): align DNS settings with Xray docs + UI polish

- DNS server modal: rename expectIPs -> expectedIPs (per docs); add
  per-server tag, clientIP, serveStale, serveExpiredTTL, timeoutMs;
  flip skipFallback default to false; hydration still accepts legacy
  expectIPs for back-compat.
- DNS tab: add hosts editor (domain -> IP/array), serveStale +
  serveExpiredTTL controls, "Use Preset" button bringing back the
  legacy preset gallery (Google / Cloudflare / AdGuard + Family
  variants — fixed AdGuard Family IPs that were wrong in legacy),
  and a "Delete All" button to wipe the server list at once.
- i18n: add 15 new dns.* keys across all 13 locales.
- Frontend-wide formatter pass on Vue components (whitespace and
  attribute layout only, no behavior changes).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
MHSanaei 13 ساعت پیش
والد
کامیت
a96612f595
50فایلهای تغییر یافته به همراه1202 افزوده شده و 885 حذف شده
  1. 15 38
      frontend/src/components/AppSidebar.vue
  2. 6 2
      frontend/src/components/CustomStatistic.vue
  3. 11 29
      frontend/src/components/DateTimePicker.vue
  4. 71 103
      frontend/src/components/FinalMaskForm.vue
  5. 3 10
      frontend/src/components/InfinityIcon.vue
  6. 5 23
      frontend/src/components/PromptModal.vue
  7. 6 2
      frontend/src/components/SettingListItem.vue
  8. 16 66
      frontend/src/components/Sparkline.vue
  9. 15 4
      frontend/src/components/TableSortable.vue
  10. 7 8
      frontend/src/components/TextModal.vue
  11. 1 1
      frontend/src/pages/inbounds/ClientFormModal.vue
  12. 12 3
      frontend/src/pages/inbounds/ClientRowTable.vue
  13. 4 12
      frontend/src/pages/inbounds/InboundFormModal.vue
  14. 8 14
      frontend/src/pages/inbounds/InboundInfoModal.vue
  15. 21 11
      frontend/src/pages/inbounds/InboundList.vue
  16. 5 5
      frontend/src/pages/inbounds/InboundsPage.vue
  17. 1 6
      frontend/src/pages/inbounds/QrPanel.vue
  18. 62 27
      frontend/src/pages/index/LogModal.vue
  19. 8 8
      frontend/src/pages/index/StatusCard.vue
  20. 3 6
      frontend/src/pages/index/SystemHistoryModal.vue
  21. 33 7
      frontend/src/pages/index/XrayLogModal.vue
  22. 7 28
      frontend/src/pages/nodes/NodeFormModal.vue
  23. 6 24
      frontend/src/pages/nodes/NodeHistoryPanel.vue
  24. 17 18
      frontend/src/pages/nodes/NodeList.vue
  25. 10 37
      frontend/src/pages/nodes/NodesPage.vue
  26. 1 6
      frontend/src/pages/settings/SecurityTab.vue
  27. 38 34
      frontend/src/pages/sub/SubPage.vue
  28. 2 12
      frontend/src/pages/xray/BalancerFormModal.vue
  29. 103 0
      frontend/src/pages/xray/DnsPresetsModal.vue
  30. 71 49
      frontend/src/pages/xray/DnsServerModal.vue
  31. 197 59
      frontend/src/pages/xray/DnsTab.vue
  32. 3 7
      frontend/src/pages/xray/OutboundFormModal.vue
  33. 74 55
      frontend/src/pages/xray/OutboundsTab.vue
  34. 37 28
      frontend/src/pages/xray/RoutingTab.vue
  35. 35 23
      frontend/src/pages/xray/RuleFormModal.vue
  36. 38 24
      frontend/src/pages/xray/WarpModal.vue
  37. 42 83
      frontend/src/pages/xray/XrayPage.vue
  38. 16 1
      web/translation/ar-EG.json
  39. 16 1
      web/translation/en-US.json
  40. 16 1
      web/translation/es-ES.json
  41. 16 1
      web/translation/fa-IR.json
  42. 16 1
      web/translation/id-ID.json
  43. 16 1
      web/translation/ja-JP.json
  44. 16 1
      web/translation/pt-BR.json
  45. 16 1
      web/translation/ru-RU.json
  46. 16 1
      web/translation/tr-TR.json
  47. 16 1
      web/translation/uk-UA.json
  48. 16 1
      web/translation/vi-VN.json
  49. 16 1
      web/translation/zh-CN.json
  50. 16 1
      web/translation/zh-TW.json

+ 15 - 38
frontend/src/components/AppSidebar.vue

@@ -50,12 +50,12 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
 // Labels are i18n-driven so the sidebar matches the locale picked
 // in panel settings without a page reload of the sidebar component.
 const tabs = computed(() => [
-  { key: `${prefix}panel/`,         icon: 'dashboard', title: t('menu.dashboard') },
-  { key: `${prefix}panel/inbounds`, icon: 'user',      title: t('menu.inbounds') },
-  { key: `${prefix}panel/nodes`,    icon: 'cluster',   title: t('menu.nodes') },
-  { key: `${prefix}panel/settings`, icon: 'setting',   title: t('menu.settings') },
-  { key: `${prefix}panel/xray`,     icon: 'tool',      title: t('menu.xray') },
-  { key: `${prefix}logout`,         icon: 'logout',    title: t('logout') },
+  { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
+  { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
+  { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
+  { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
+  { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
+  { key: `${prefix}logout`, icon: 'logout', title: t('logout') },
 ]);
 
 const activeTab = ref([props.requestUri]);
@@ -90,20 +90,9 @@ function closeDrawer() {
 
 <template>
   <div class="ant-sidebar">
-    <a-layout-sider
-      :theme="currentTheme"
-      collapsible
-      :collapsed="collapsed"
-      breakpoint="md"
-      @collapse="onCollapse"
-    >
+    <a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
       <ThemeSwitch />
-      <a-menu
-        :theme="currentTheme"
-        mode="inline"
-        :selected-keys="activeTab"
-        @click="({ key }) => openLink(key)"
-      >
+      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
         <a-menu-item v-for="tab in tabs" :key="tab.key">
           <component :is="iconByName[tab.icon]" />
           <span>{{ tab.title }}</span>
@@ -111,22 +100,10 @@ function closeDrawer() {
       </a-menu>
     </a-layout-sider>
 
-    <a-drawer
-      placement="left"
-      :closable="false"
-      :open="drawerOpen"
-      :wrap-class-name="currentTheme"
-      :wrap-style="{ padding: 0 }"
-      :style="{ height: '100%' }"
-      @close="closeDrawer"
-    >
+    <a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
+      :wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
       <ThemeSwitch />
-      <a-menu
-        :theme="currentTheme"
-        mode="inline"
-        :selected-keys="activeTab"
-        @click="({ key }) => openLink(key)"
-      >
+      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
         <a-menu-item v-for="tab in tabs" :key="tab.key">
           <component :is="iconByName[tab.icon]" />
           <span>{{ tab.title }}</span>
@@ -142,7 +119,7 @@ function closeDrawer() {
 </template>
 
 <style scoped>
-.ant-sidebar > .ant-layout-sider {
+.ant-sidebar>.ant-layout-sider {
   height: 100%;
 }
 
@@ -171,12 +148,12 @@ function closeDrawer() {
   /* On mobile the drawer is the menu — hide the inline sider's content
    * + the collapse trigger so the sider stops taking layout space and
    * leaves no remnant button next to the page. */
-  .ant-sidebar > .ant-layout-sider :deep(.ant-layout-sider-children),
-  .ant-sidebar > .ant-layout-sider :deep(.ant-layout-sider-trigger) {
+  .ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
+  .ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
     display: none;
   }
 
-  .ant-sidebar > .ant-layout-sider {
+  .ant-sidebar>.ant-layout-sider {
     flex: 0 0 0 !important;
     max-width: 0 !important;
     min-width: 0 !important;

+ 6 - 2
frontend/src/components/CustomStatistic.vue

@@ -7,8 +7,12 @@ defineProps({
 
 <template>
   <a-statistic :title="title" :value="value">
-    <template #prefix><slot name="prefix" /></template>
-    <template #suffix><slot name="suffix" /></template>
+    <template #prefix>
+      <slot name="prefix" />
+    </template>
+    <template #suffix>
+      <slot name="suffix" />
+    </template>
   </a-statistic>
 </template>
 

+ 11 - 29
frontend/src/components/DateTimePicker.vue

@@ -51,29 +51,11 @@ function onAntChange(next) {
 </script>
 
 <template>
-  <PersianDatePicker
-    v-if="isJalali"
-    v-model="stringValue"
-    :format="ISO_FORMAT"
-    :display-format="persianDisplayFormat"
-    :placeholder="placeholder"
-    :disabled="disabled"
-    color="#1677ff"
-    auto-submit
-    append-to="body"
-    input-class="ant-input persian-datepicker-input"
-    class="jalali-datepicker"
-  />
-  <a-date-picker
-    v-else
-    :value="value"
-    :show-time="showTime ? { format: 'HH:mm:ss' } : false"
-    :format="format"
-    :placeholder="placeholder"
-    :disabled="disabled"
-    :style="{ width: '100%' }"
-    @update:value="onAntChange"
-  />
+  <PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
+    :placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
+    input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
+  <a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
+    :placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
 </template>
 
 <style scoped>
@@ -142,8 +124,8 @@ function onAntChange(next) {
   background: #fff;
   color: rgba(0, 0, 0, 0.88);
   box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
-              0 3px 6px -4px rgba(0, 0, 0, 0.12),
-              0 9px 28px 8px rgba(0, 0, 0, 0.05);
+    0 3px 6px -4px rgba(0, 0, 0, 0.12),
+    0 9px 28px 8px rgba(0, 0, 0, 0.05);
   border-radius: 8px;
   overflow: hidden;
 }
@@ -166,7 +148,7 @@ function onAntChange(next) {
 }
 
 .vpd-wrapper .vpd-body .vpd-month-label,
-.vpd-wrapper .vpd-body .vpd-month-label > span {
+.vpd-wrapper .vpd-body .vpd-month-label>span {
   color: rgba(0, 0, 0, 0.88);
 }
 
@@ -271,8 +253,8 @@ body.dark .vpd-wrapper .vpd-content {
   background: #1a2c4d;
   color: rgba(255, 255, 255, 0.88);
   box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
-              0 3px 6px -4px rgba(0, 0, 0, 0.48),
-              0 9px 28px 8px rgba(0, 0, 0, 0.2);
+    0 3px 6px -4px rgba(0, 0, 0, 0.48),
+    0 9px 28px 8px rgba(0, 0, 0, 0.2);
 }
 
 body.dark .vpd-wrapper .vpd-body {
@@ -281,7 +263,7 @@ body.dark .vpd-wrapper .vpd-body {
 }
 
 body.dark .vpd-wrapper .vpd-body .vpd-month-label,
-body.dark .vpd-wrapper .vpd-body .vpd-month-label > span {
+body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
   color: rgba(255, 255, 255, 0.88);
 }
 

+ 71 - 103
frontend/src/components/FinalMaskForm.vue

@@ -66,27 +66,23 @@ function newNoiseItem() {
 </script>
 
 <template>
-  <a-form
-    v-if="showTcp || showUdp || showQuic"
-    :colon="false"
-    :label-col="{ md: { span: 8 } }"
-    :wrapper-col="{ md: { span: 14 } }"
-  >
+  <a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
+    :wrapper-col="{ md: { span: 14 } }">
     <!-- ============================== TCP MASKS ============================== -->
     <template v-if="showTcp">
       <a-form-item label="TCP Masks">
         <a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
-          <template #icon><PlusOutlined /></template>
+          <template #icon>
+            <PlusOutlined />
+          </template>
         </a-button>
       </a-form-item>
 
       <template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
         <a-divider :style="{ margin: '0' }">
           TCP Mask {{ mIdx + 1 }}
-          <DeleteOutlined
-            :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
-            @click="stream.delTcpMask(mIdx)"
-          />
+          <DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+            @click="stream.delTcpMask(mIdx)" />
         </a-divider>
 
         <a-form-item label="Type">
@@ -144,16 +140,16 @@ function newNoiseItem() {
           <!-- Clients -->
           <a-form-item label="Clients">
             <a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
-              <template #icon><PlusOutlined /></template>
+              <template #icon>
+                <PlusOutlined />
+              </template>
             </a-button>
           </a-form-item>
           <template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
             <a-divider :style="{ margin: '0' }">
               Clients Group {{ gi + 1 }}
-              <DeleteOutlined
-                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
-                @click="mask.settings.clients.splice(gi, 1)"
-              />
+              <DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.clients.splice(gi, 1)" />
             </a-divider>
             <template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
               <a-form-item label="Type">
@@ -177,13 +173,12 @@ function newNoiseItem() {
               </template>
               <a-form-item v-else label="Packet">
                 <a-input-group v-if="item.type === 'base64'" compact>
-                  <a-input
-                    v-model:value="item.packet"
-                    placeholder="binary data"
-                    :style="{ width: 'calc(100% - 32px)' }"
-                  />
+                  <a-input v-model:value="item.packet" placeholder="binary data"
+                    :style="{ width: 'calc(100% - 32px)' }" />
                   <a-button @click="item.packet = RandomUtil.randomBase64()">
-                    <template #icon><ReloadOutlined /></template>
+                    <template #icon>
+                      <ReloadOutlined />
+                    </template>
                   </a-button>
                 </a-input-group>
                 <a-input v-else v-model:value="item.packet" placeholder="binary data" />
@@ -194,16 +189,16 @@ function newNoiseItem() {
           <!-- Servers -->
           <a-form-item label="Servers">
             <a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
-              <template #icon><PlusOutlined /></template>
+              <template #icon>
+                <PlusOutlined />
+              </template>
             </a-button>
           </a-form-item>
           <template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
             <a-divider :style="{ margin: '0' }">
               Servers Group {{ gi + 1 }}
-              <DeleteOutlined
-                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
-                @click="mask.settings.servers.splice(gi, 1)"
-              />
+              <DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.servers.splice(gi, 1)" />
             </a-divider>
             <template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
               <a-form-item label="Type">
@@ -227,13 +222,12 @@ function newNoiseItem() {
               </template>
               <a-form-item v-else label="Packet">
                 <a-input-group v-if="item.type === 'base64'" compact>
-                  <a-input
-                    v-model:value="item.packet"
-                    placeholder="binary data"
-                    :style="{ width: 'calc(100% - 32px)' }"
-                  />
+                  <a-input v-model:value="item.packet" placeholder="binary data"
+                    :style="{ width: 'calc(100% - 32px)' }" />
                   <a-button @click="item.packet = RandomUtil.randomBase64()">
-                    <template #icon><ReloadOutlined /></template>
+                    <template #icon>
+                      <ReloadOutlined />
+                    </template>
                   </a-button>
                 </a-input-group>
                 <a-input v-else v-model:value="item.packet" placeholder="binary data" />
@@ -248,17 +242,17 @@ function newNoiseItem() {
     <template v-if="showUdp">
       <a-form-item label="UDP Masks">
         <a-button type="primary" size="small" @click="addUdpMaskWithDefault">
-          <template #icon><PlusOutlined /></template>
+          <template #icon>
+            <PlusOutlined />
+          </template>
         </a-button>
       </a-form-item>
 
       <template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
         <a-divider :style="{ margin: '0' }">
           UDP Mask {{ mIdx + 1 }}
-          <DeleteOutlined
-            :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
-            @click="stream.delUdpMask(mIdx)"
-          />
+          <DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+            @click="stream.delUdpMask(mIdx)" />
         </a-divider>
 
         <a-form-item label="Type">
@@ -290,13 +284,8 @@ function newNoiseItem() {
           <a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
         </a-form-item>
         <a-form-item v-if="mask.type === 'xdns'" label="Domains">
-          <a-select
-            v-model:value="mask.settings.domains"
-            mode="tags"
-            :style="{ width: '100%' }"
-            :token-separators="[',']"
-            placeholder="e.g., www.example.com"
-          />
+          <a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
+            :token-separators="[',']" placeholder="e.g., www.example.com" />
         </a-form-item>
 
         <!-- Noise -->
@@ -306,16 +295,16 @@ function newNoiseItem() {
           </a-form-item>
           <a-form-item label="Noise">
             <a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
-              <template #icon><PlusOutlined /></template>
+              <template #icon>
+                <PlusOutlined />
+              </template>
             </a-button>
           </a-form-item>
           <template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
             <a-divider :style="{ margin: '0' }">
               Noise {{ ni + 1 }}
-              <DeleteOutlined
-                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
-                @click="mask.settings.noise.splice(ni, 1)"
-              />
+              <DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.noise.splice(ni, 1)" />
             </a-divider>
             <a-form-item label="Type">
               <a-select :value="n.type" @change="(t) => changeItemType(n, t)">
@@ -335,13 +324,11 @@ function newNoiseItem() {
             </template>
             <a-form-item v-else label="Packet">
               <a-input-group v-if="n.type === 'base64'" compact>
-                <a-input
-                  v-model:value="n.packet"
-                  placeholder="binary data"
-                  :style="{ width: 'calc(100% - 32px)' }"
-                />
+                <a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
                 <a-button @click="n.packet = RandomUtil.randomBase64()">
-                  <template #icon><ReloadOutlined /></template>
+                  <template #icon>
+                    <ReloadOutlined />
+                  </template>
                 </a-button>
               </a-input-group>
               <a-input v-else v-model:value="n.packet" placeholder="binary data" />
@@ -356,16 +343,16 @@ function newNoiseItem() {
         <template v-if="mask.type === 'header-custom'">
           <a-form-item label="Client">
             <a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
-              <template #icon><PlusOutlined /></template>
+              <template #icon>
+                <PlusOutlined />
+              </template>
             </a-button>
           </a-form-item>
           <template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
             <a-divider :style="{ margin: '0' }">
               Client {{ ci + 1 }}
-              <DeleteOutlined
-                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
-                @click="mask.settings.client.splice(ci, 1)"
-              />
+              <DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.client.splice(ci, 1)" />
             </a-divider>
             <a-form-item label="Type">
               <a-select :value="c.type" @change="(t) => changeItemType(c, t)">
@@ -385,13 +372,11 @@ function newNoiseItem() {
             </template>
             <a-form-item v-else label="Packet">
               <a-input-group v-if="c.type === 'base64'" compact>
-                <a-input
-                  v-model:value="c.packet"
-                  placeholder="binary data"
-                  :style="{ width: 'calc(100% - 32px)' }"
-                />
+                <a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
                 <a-button @click="c.packet = RandomUtil.randomBase64()">
-                  <template #icon><ReloadOutlined /></template>
+                  <template #icon>
+                    <ReloadOutlined />
+                  </template>
                 </a-button>
               </a-input-group>
               <a-input v-else v-model:value="c.packet" placeholder="binary data" />
@@ -401,16 +386,16 @@ function newNoiseItem() {
           <a-divider :style="{ margin: '0' }" />
           <a-form-item label="Server">
             <a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
-              <template #icon><PlusOutlined /></template>
+              <template #icon>
+                <PlusOutlined />
+              </template>
             </a-button>
           </a-form-item>
           <template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
             <a-divider :style="{ margin: '0' }">
               Server {{ si + 1 }}
-              <DeleteOutlined
-                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
-                @click="mask.settings.server.splice(si, 1)"
-              />
+              <DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.server.splice(si, 1)" />
             </a-divider>
             <a-form-item label="Type">
               <a-select :value="s.type" @change="(t) => changeItemType(s, t)">
@@ -430,13 +415,11 @@ function newNoiseItem() {
             </template>
             <a-form-item v-else label="Packet">
               <a-input-group v-if="s.type === 'base64'" compact>
-                <a-input
-                  v-model:value="s.packet"
-                  placeholder="binary data"
-                  :style="{ width: 'calc(100% - 32px)' }"
-                />
+                <a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
                 <a-button @click="s.packet = RandomUtil.randomBase64()">
-                  <template #icon><ReloadOutlined /></template>
+                  <template #icon>
+                    <ReloadOutlined />
+                  </template>
                 </a-button>
               </a-input-group>
               <a-input v-else v-model:value="s.packet" placeholder="binary data" />
@@ -502,39 +485,24 @@ function newNoiseItem() {
           <a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
         </a-form-item>
         <a-form-item label="Max Incoming Streams">
-          <a-input-number
-            v-model:value="stream.finalmask.quicParams.maxIncomingStreams"
-            :min="8"
-            placeholder="1024 = default"
-          />
+          <a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
+            placeholder="1024 = default" />
         </a-form-item>
         <a-form-item label="Init Stream Window">
-          <a-input-number
-            v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow"
-            :min="16384"
-            placeholder="8388608 = default"
-          />
+          <a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
+            placeholder="8388608 = default" />
         </a-form-item>
         <a-form-item label="Max Stream Window">
-          <a-input-number
-            v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow"
-            :min="16384"
-            placeholder="8388608 = default"
-          />
+          <a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
+            placeholder="8388608 = default" />
         </a-form-item>
         <a-form-item label="Init Conn Window">
-          <a-input-number
-            v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow"
-            :min="16384"
-            placeholder="20971520 = default"
-          />
+          <a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
+            placeholder="20971520 = default" />
         </a-form-item>
         <a-form-item label="Max Conn Window">
-          <a-input-number
-            v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow"
-            :min="16384"
-            placeholder="20971520 = default"
-          />
+          <a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
+            placeholder="20971520 = default" />
         </a-form-item>
       </template>
     </template>

+ 3 - 10
frontend/src/components/InfinityIcon.vue

@@ -10,16 +10,9 @@ defineProps({
 </script>
 
 <template>
-  <svg
-    :width="width"
-    :height="height"
-    viewBox="0 0 640 512"
-    fill="currentColor"
-    aria-hidden="true"
-    style="vertical-align: -1px; display: inline-block;"
-  >
+  <svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
+    style="vertical-align: -1px; display: inline-block;">
     <path
-      d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
-    />
+      d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
   </svg>
 </template>

+ 5 - 23
frontend/src/components/PromptModal.vue

@@ -43,28 +43,10 @@ function onKeydown(e) {
 </script>
 
 <template>
-  <a-modal
-    :open="open"
-    :title="title"
-    :ok-text="okText"
-    cancel-text="Cancel"
-    :mask-closable="false"
-    :confirm-loading="loading"
-    @ok="ok"
-    @cancel="close"
-  >
-    <a-textarea
-      v-if="type === 'textarea'"
-      v-model:value="value"
-      :auto-size="{ minRows: 10, maxRows: 20 }"
-      autofocus
-      @keydown="onKeydown"
-    />
-    <a-input
-      v-else
-      v-model:value="value"
-      autofocus
-      @keydown="onKeydown"
-    />
+  <a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
+    :confirm-loading="loading" @ok="ok" @cancel="close">
+    <a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
+      @keydown="onKeydown" />
+    <a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
   </a-modal>
 </template>

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

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

+ 16 - 66
frontend/src/components/Sparkline.vue

@@ -220,16 +220,8 @@ const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
 </script>
 
 <template>
-  <svg
-    ref="svgRef"
-    width="100%"
-    :height="height"
-    :viewBox="viewBoxAttr"
-    preserveAspectRatio="none"
-    class="sparkline-svg"
-    @mousemove="onMouseMove"
-    @mouseleave="onMouseLeave"
-  >
+  <svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
+    class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
     <defs>
       <linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
         <stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
@@ -238,70 +230,28 @@ const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
     </defs>
 
     <g v-if="showGrid">
-      <line
-        v-for="(g, i) in gridLines"
-        :key="i"
-        :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2"
-        :stroke="gridColor" stroke-width="1"
-        class="cpu-grid-line"
-      />
+      <line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
+        stroke-width="1" class="cpu-grid-line" />
     </g>
 
     <g v-if="showAxes">
-      <text
-        v-for="(t, i) in yTicks"
-        :key="'y' + i"
-        class="cpu-grid-y-text"
-        :x="Math.max(0, paddingLeft - 4)"
-        :y="t.y + 4"
-        text-anchor="end"
-        font-size="10"
-      >{{ t.label }}</text>
-      <text
-        v-for="(t, i) in xTicks"
-        :key="'x' + i"
-        class="cpu-grid-x-text"
-        :x="t.x"
-        :y="paddingTop + drawHeight + 14"
-        text-anchor="middle"
-        font-size="10"
-      >{{ t.label }}</text>
+      <text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
+        :y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
+      <text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
+        text-anchor="middle" font-size="10">{{ t.label }}</text>
     </g>
 
     <path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
-    <polyline
-      :points="pointsStr"
-      fill="none"
-      :stroke="stroke"
-      :stroke-width="strokeWidth"
-      stroke-linecap="round"
-      stroke-linejoin="round"
-    />
-    <circle
-      v-if="showMarker && lastPoint"
-      :cx="lastPoint[0]" :cy="lastPoint[1]"
-      :r="markerRadius"
-      :fill="stroke"
-    />
+    <polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
+      stroke-linejoin="round" />
+    <circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
 
     <g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
-      <line
-        class="cpu-grid-h-line"
-        :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]"
-        :y1="paddingTop" :y2="paddingTop + drawHeight"
-        stroke="rgba(0,0,0,0.2)" stroke-width="1"
-      />
-      <circle
-        :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]"
-        r="3.5" :fill="stroke"
-      />
-      <text
-        class="cpu-grid-text"
-        :x="pointsArr[hoverIdx][0]"
-        :y="paddingTop + 12"
-        text-anchor="middle"
-        font-size="11"
-      >{{ fmtHoverText() }}</text>
+      <line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
+        :y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
+      <circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
+      <text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
+        font-size="11">{{ fmtHoverText() }}</text>
     </g>
   </svg>
 </template>

+ 15 - 4
frontend/src/components/TableSortable.vue

@@ -266,33 +266,44 @@ export default defineComponent({
   user-select: none;
   touch-action: none;
 }
+
 .sortable-icon:hover {
   color: rgba(255, 255, 255, 0.85);
   background: rgba(255, 255, 255, 0.06);
 }
-.sortable-icon:active { cursor: grabbing; }
+
+.sortable-icon:active {
+  cursor: grabbing;
+}
+
 .sortable-icon:focus-visible {
   outline: 2px solid #008771;
   outline-offset: 2px;
 }
 
-.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
+.light .sortable-icon {
+  color: rgba(0, 0, 0, 0.45);
+}
+
 .light .sortable-icon:hover {
   color: rgba(0, 0, 0, 0.85);
   background: rgba(0, 0, 0, 0.05);
 }
 
-.sortable-table-dragging .sortable-source-row > td {
+.sortable-table-dragging .sortable-source-row>td {
   background: rgba(0, 135, 113, 0.10) !important;
   transition: background-color 0.18s ease;
 }
+
 .sortable-table-dragging .sortable-source-row .routing-index,
 .sortable-table-dragging .sortable-source-row .outbound-index {
   opacity: 0.45;
 }
-.sortable-table-dragging .sortable-row > td {
+
+.sortable-table-dragging .sortable-row>td {
   transition: background-color 0.18s ease;
 }
+
 .sortable-table-dragging,
 .sortable-table-dragging * {
   user-select: none;

+ 7 - 8
frontend/src/components/TextModal.vue

@@ -39,19 +39,18 @@ function download(content, name) {
 
 <template>
   <a-modal :open="open" :title="title" :closable="true" @cancel="close">
-    <a-textarea
-      :value="content"
-      readonly
-      :auto-size="{ minRows: 10, maxRows: 20 }"
-      class="text-modal-content"
-    />
+    <a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
     <template #footer>
       <a-button v-if="fileName" @click="download(content, fileName)">
-        <template #icon><DownloadOutlined /></template>
+        <template #icon>
+          <DownloadOutlined />
+        </template>
         {{ fileName }}
       </a-button>
       <a-button type="primary" @click="copy(content)">
-        <template #icon><CopyOutlined /></template>
+        <template #icon>
+          <CopyOutlined />
+        </template>
         Copy
       </a-button>
     </template>

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

@@ -362,7 +362,7 @@ const title = computed(() =>
       <a-form-item v-else>
         <template #label>
           <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
-            }}</a-tooltip>
+          }}</a-tooltip>
         </template>
         <DateTimePicker v-model:value="expiryDate" />
         <a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>

+ 12 - 3
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -291,8 +291,8 @@ function rowKey(client) {
               {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
             </a-tag>
           </a-popover>
-          <a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)"
-            :style="{ border: 'none' }" class="infinite-tag">
+          <a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
+            class="infinite-tag">
             <InfinityIcon />
           </a-tag>
         </div>
@@ -373,7 +373,9 @@ function rowKey(client) {
             <a-tag v-else-if="client.expiryTime < 0" color="green">
               {{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
             </a-tag>
-            <a-tag v-else color="purple"><InfinityIcon /></a-tag>
+            <a-tag v-else color="purple">
+              <InfinityIcon />
+            </a-tag>
           </div>
         </div>
       </div>
@@ -561,6 +563,7 @@ function rowKey(client) {
   flex-direction: column;
   gap: 6px;
 }
+
 :global(body.dark) .client-card {
   border-color: rgba(255, 255, 255, 0.1);
 }
@@ -571,6 +574,7 @@ function rowKey(client) {
   gap: 8px;
   min-width: 0;
 }
+
 .client-card-head .client-email {
   flex: 1;
   min-width: 0;
@@ -580,6 +584,7 @@ function rowKey(client) {
   overflow: hidden;
   text-overflow: ellipsis;
 }
+
 .client-card-actions {
   margin-left: auto;
   display: flex;
@@ -587,6 +592,7 @@ function rowKey(client) {
   gap: 8px;
   flex-shrink: 0;
 }
+
 .client-card-actions .row-icon {
   font-size: 20px;
   padding: 4px;
@@ -605,12 +611,14 @@ function rowKey(client) {
   flex-direction: column;
   gap: 4px;
 }
+
 .client-card-foot .stat-row {
   display: flex;
   align-items: center;
   flex-wrap: wrap;
   gap: 6px;
 }
+
 .client-card-foot .stat-label {
   font-size: 10px;
   text-transform: uppercase;
@@ -619,6 +627,7 @@ function rowKey(client) {
   min-width: 96px;
   flex-shrink: 0;
 }
+
 .client-card-foot :deep(.ant-tag) {
   margin: 0;
 }

+ 4 - 12
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -560,19 +560,11 @@ watch(
             <a-input v-model:value="dbForm.remark" />
           </a-form-item>
           <a-form-item :label="t('pages.inbounds.deployTo')">
-            <a-select
-              v-model:value="dbForm.nodeId"
-              :disabled="mode === 'edit'"
-              :placeholder="t('pages.inbounds.localPanel')"
-              allow-clear
-            >
+            <a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
+              :placeholder="t('pages.inbounds.localPanel')" allow-clear>
               <a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
-              <a-select-option
-                v-for="n in selectableNodes"
-                :key="n.id"
-                :value="n.id"
-                :disabled="n.status === 'offline'"
-              >
+              <a-select-option v-for="n in selectableNodes" :key="n.id" :value="n.id"
+                :disabled="n.status === 'offline'">
                 {{ n.name }}{{ n.status === 'offline' ? ' (offline)' : '' }}
               </a-select-option>
             </a-select>

+ 8 - 14
frontend/src/pages/inbounds/InboundInfoModal.vue

@@ -327,14 +327,16 @@ const showSubscriptionTab = computed(
               <tr>
                 <td>{{ t('pages.inbounds.createdAt') }}</td>
                 <td>
-                  <a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker) }}</a-tag>
+                  <a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker)
+                  }}</a-tag>
                   <a-tag v-else>-</a-tag>
                 </td>
               </tr>
               <tr>
                 <td>{{ t('pages.inbounds.updatedAt') }}</td>
                 <td>
-                  <a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker) }}</a-tag>
+                  <a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker)
+                  }}</a-tag>
                   <a-tag v-else>-</a-tag>
                 </td>
               </tr>
@@ -356,7 +358,7 @@ const showSubscriptionTab = computed(
                   <div class="ip-log">
                     <div v-if="clientIpsArray.length > 0">
                       <a-tag v-for="(item, idx) in clientIpsArray" :key="idx" color="blue" class="ip-log-row">{{ item
-                        }}</a-tag>
+                      }}</a-tag>
                     </div>
                     <a-tag v-else>{{ clientIpsText || t('tgbot.noIpRecord') }}</a-tag>
                   </div>
@@ -472,7 +474,7 @@ const showSubscriptionTab = computed(
                 </a-tooltip>
               </div>
               <a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink
-                }}</a>
+              }}</a>
             </div>
           </template>
         </a-tab-pane>
@@ -627,11 +629,7 @@ const showSubscriptionTab = computed(
               <dd><a-tag class="value-tag">{{ inbound.settings.ip }}</a-tag></dd>
             </div>
             <template v-if="inbound.settings.auth === 'password' && inbound.settings.accounts?.length">
-              <div
-                v-for="(account, idx) in inbound.settings.accounts"
-                :key="idx"
-                class="info-row"
-              >
+              <div v-for="(account, idx) in inbound.settings.accounts" :key="idx" class="info-row">
                 <dt>{{ t('username') }} #{{ idx + 1 }}</dt>
                 <dd class="account-row">
                   <a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
@@ -651,11 +649,7 @@ const showSubscriptionTab = computed(
 
           <!-- HTTP accounts -->
           <dl v-if="dbInbound.isHTTP && inbound.settings.accounts?.length" class="info-list info-list-block">
-            <div
-              v-for="(account, idx) in inbound.settings.accounts"
-              :key="idx"
-              class="info-row"
-            >
+            <div v-for="(account, idx) in inbound.settings.accounts" :key="idx" class="info-row">
               <dt>{{ t('username') }} #{{ idx + 1 }}</dt>
               <dd class="account-row">
                 <a-tag color="green" class="value-tag">{{ account.user }}</a-tag>

+ 21 - 11
frontend/src/pages/inbounds/InboundList.vue

@@ -276,8 +276,7 @@ function showQrCodeMenu(dbInbound) {
             <span class="card-id">#{{ record.id }}</span>
             <span class="tag-name">{{ record.remark }}</span>
             <div class="card-actions" @click.stop>
-              <a-switch :checked="record.enable" size="small"
-                @change="(next) => onSwitchEnable(record, next)" />
+              <a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
               <a-dropdown :trigger="['click']" placement="bottomRight">
                 <MoreOutlined class="row-action-trigger" @click.prevent />
                 <template #overlay>
@@ -391,17 +390,17 @@ function showQrCodeMenu(dbInbound) {
                 :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
                 {{ IntlUtil.formatRelativeTime(record.expiryTime) }}
               </a-tag>
-              <a-tag v-else color="purple"><InfinityIcon /></a-tag>
+              <a-tag v-else color="purple">
+                <InfinityIcon />
+              </a-tag>
             </div>
           </div>
 
           <!-- Expanded client list (multi-user only) -->
           <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
-            <ClientRowTable :db-inbound="record" :is-mobile="true"
-              :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
-              :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
-              @edit-client="(p) => emit('edit-client', p)"
-              @qrcode-client="(p) => emit('qrcode-client', p)"
+            <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
+              :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
+              @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
               @info-client="(p) => emit('info-client', p)"
               @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
               @delete-client="(p) => emit('delete-client', p)"
@@ -412,8 +411,7 @@ function showQrCodeMenu(dbInbound) {
 
       <!-- ====================== Desktop: a-table ======================== -->
       <a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
-        :pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }"
-        :style="{ marginTop: '10px' }" size="small"
+        :pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
         :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
         <!-- Per-inbound client list, expanded by clicking the row's
              default expand chevron. Hidden via row-class-name for
@@ -697,6 +695,7 @@ function showQrCodeMenu(dbInbound) {
   flex-direction: column;
   gap: 8px;
 }
+
 :global(body.dark) .inbound-card {
   background: rgba(255, 255, 255, 0.03);
   border-color: rgba(255, 255, 255, 0.1);
@@ -709,10 +708,12 @@ function showQrCodeMenu(dbInbound) {
   cursor: pointer;
   user-select: none;
 }
+
 .card-id {
   font-size: 11px;
   opacity: 0.6;
 }
+
 .tag-name {
   font-weight: 600;
   flex: 1;
@@ -721,18 +722,21 @@ function showQrCodeMenu(dbInbound) {
   text-overflow: ellipsis;
   white-space: nowrap;
 }
+
 .card-actions {
   display: flex;
   align-items: center;
   gap: 8px;
   flex-shrink: 0;
 }
+
 .card-expand {
   font-size: 12px;
   opacity: 0.6;
   transition: transform 150ms ease;
   flex-shrink: 0;
 }
+
 .card-expand.is-expanded {
   transform: rotate(90deg);
 }
@@ -742,12 +746,14 @@ function showQrCodeMenu(dbInbound) {
   flex-direction: column;
   gap: 6px;
 }
+
 .stat-row {
   display: flex;
   align-items: center;
   flex-wrap: wrap;
   gap: 6px;
 }
+
 .stat-label {
   font-size: 10px;
   text-transform: uppercase;
@@ -756,6 +762,7 @@ function showQrCodeMenu(dbInbound) {
   min-width: 96px;
   flex-shrink: 0;
 }
+
 .card-stats :deep(.ant-tag) {
   margin: 0;
 }
@@ -777,10 +784,12 @@ function showQrCodeMenu(dbInbound) {
     padding: 0 12px;
     min-height: 44px;
   }
+
   :deep(.ant-card-head-title),
   :deep(.ant-card-extra) {
     padding: 8px 0;
   }
+
   :deep(.ant-card-body) {
     padding: 8px;
   }
@@ -790,7 +799,8 @@ function showQrCodeMenu(dbInbound) {
     flex-wrap: wrap;
     gap: 6px;
   }
-  .filter-bar.mobile > * {
+
+  .filter-bar.mobile>* {
     margin-bottom: 0;
   }
 

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

@@ -608,11 +608,11 @@ function onRowAction({ key, dbInbound }) {
               <!-- Inbound list — toolbar, search/filter, columns, row actions -->
               <a-col :span="24">
                 <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
-                  :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark"
-                  :expire-diff="expireDiff" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
-                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh" @add-inbound="onAddInbound"
-                  @general-action="onGeneralAction" @row-action="onRowAction" @edit-client="onEditClient"
-                  @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
+                  :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
+                  :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
+                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
+                  @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
+                  @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
                   @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
                   @toggle-enable-client="onToggleEnableClient" />
               </a-col>

+ 1 - 6
frontend/src/pages/inbounds/QrPanel.vue

@@ -107,11 +107,7 @@ function download() {
       </a-tooltip>
     </div>
     <div v-if="showQr" class="qr-panel-canvas">
-      <canvas
-        ref="canvas"
-        :style="{ width: `${size}px`, height: `${size}px` }"
-        @click="copy"
-      />
+      <canvas ref="canvas" :style="{ width: `${size}px`, height: `${size}px` }" @click="copy" />
     </div>
   </div>
 </template>
@@ -154,5 +150,4 @@ function download() {
   image-rendering: pixelated;
   image-rendering: crisp-edges;
 }
-
 </style>

+ 62 - 27
frontend/src/pages/index/LogModal.vue

@@ -89,8 +89,8 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
 </script>
 
 <template>
-  <a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
-    :class="{ 'logmodal-mobile': isMobile }" @cancel="close">
+  <a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" :class="{ 'logmodal-mobile': isMobile }"
+    @cancel="close">
     <template #title>
       {{ t('pages.index.logs') }}
       <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
@@ -178,6 +178,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   flex-wrap: wrap;
   row-gap: 8px;
 }
+
 .log-toolbar .download-item {
   margin-left: auto;
 }
@@ -185,12 +186,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
 .log-container {
   /* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
      below so each level keeps ≥4.5:1 contrast against the container. */
-  --log-stamp:   #3c89e8;
-  --log-debug:   #3c89e8;
-  --log-info:    #008771;
-  --log-notice:  #008771;
+  --log-stamp: #3c89e8;
+  --log-debug: #3c89e8;
+  --log-info: #008771;
+  --log-notice: #008771;
   --log-warning: #f37b24;
-  --log-error:   #e04141;
+  --log-error: #e04141;
   --log-unknown: #595959;
   --log-divider: rgba(128, 128, 128, 0.18);
 
@@ -208,14 +209,37 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   background: rgba(0, 0, 0, 0.04);
 }
 
-.log-stamp { color: var(--log-stamp); }
-.log-level { margin-left: 4px; }
-.level-debug   { color: var(--log-debug); }
-.level-info    { color: var(--log-info); }
-.level-notice  { color: var(--log-notice); }
-.level-warning { color: var(--log-warning); }
-.level-error   { color: var(--log-error); }
-.level-unknown { color: var(--log-unknown); }
+.log-stamp {
+  color: var(--log-stamp);
+}
+
+.log-level {
+  margin-left: 4px;
+}
+
+.level-debug {
+  color: var(--log-debug);
+}
+
+.level-info {
+  color: var(--log-info);
+}
+
+.level-notice {
+  color: var(--log-notice);
+}
+
+.level-warning {
+  color: var(--log-warning);
+}
+
+.level-error {
+  color: var(--log-error);
+}
+
+.level-unknown {
+  color: var(--log-unknown);
+}
 
 .log-container-mobile {
   padding: 8px;
@@ -229,7 +253,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   padding: 20px 0;
 }
 
-.log-line + .log-line {
+.log-line+.log-line {
   margin-top: 2px;
 }
 
@@ -237,7 +261,11 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   border-bottom: 1px solid var(--log-divider);
   padding: 8px 0;
 }
-.log-card:last-child { border-bottom: 0; }
+
+.log-card:last-child {
+  border-bottom: 0;
+}
+
 .log-card-head {
   display: flex;
   align-items: center;
@@ -245,6 +273,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   gap: 8px;
   margin-bottom: 4px;
 }
+
 .log-time {
   display: inline-flex;
   align-items: baseline;
@@ -253,11 +282,13 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   font-size: 12px;
   letter-spacing: 0.02em;
 }
+
 .log-date {
   font-size: 10px;
   font-weight: 500;
   opacity: 0.55;
 }
+
 .log-level-badge {
   display: inline-block;
   font-size: 10px;
@@ -270,10 +301,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   white-space: nowrap;
   background: color-mix(in srgb, currentColor 14%, transparent);
 }
+
 .log-body {
   font-size: 12px;
   word-break: break-word;
 }
+
 .log-body-text {
   margin-left: 4px;
 }
@@ -283,23 +316,23 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   border-color: rgba(255, 255, 255, 0.1);
   color: rgba(255, 255, 255, 0.88);
 
-  --log-stamp:   #6aa6ee;
-  --log-debug:   #6aa6ee;
-  --log-info:    #4ed3a6;
-  --log-notice:  #4ed3a6;
+  --log-stamp: #6aa6ee;
+  --log-debug: #6aa6ee;
+  --log-info: #4ed3a6;
+  --log-notice: #4ed3a6;
   --log-warning: #ffb872;
-  --log-error:   #ff7575;
+  --log-error: #ff7575;
   --log-unknown: #b5b5b5;
   --log-divider: rgba(255, 255, 255, 0.1);
 }
 
 :global([data-theme="ultra-dark"]) .log-container {
-  --log-stamp:   #7fb6f1;
-  --log-debug:   #7fb6f1;
-  --log-info:    #5fd9b0;
-  --log-notice:  #5fd9b0;
+  --log-stamp: #7fb6f1;
+  --log-debug: #7fb6f1;
+  --log-info: #5fd9b0;
+  --log-notice: #5fd9b0;
   --log-warning: #ffcc88;
-  --log-error:   #ff8a8a;
+  --log-error: #ff8a8a;
   --log-unknown: #c4c4c4;
   --log-divider: rgba(255, 255, 255, 0.12);
 }
@@ -310,10 +343,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
   padding-bottom: 0 !important;
   max-width: 100vw !important;
 }
+
 :global(.logmodal-mobile .ant-modal-content) {
   border-radius: 0;
   height: 100vh;
 }
+
 :global(.logmodal-mobile .ant-modal-body) {
   padding: 12px;
 }

+ 8 - 8
frontend/src/pages/index/StatusCard.vue

@@ -30,8 +30,8 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
       <a-col :xs="24" :md="12">
         <a-row>
           <a-col :span="12" class="text-center">
-            <a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color"
-              :trail-color="trailColor" :percent="status.cpu.percent" :width="gaugeSize" />
+            <a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color" :trail-color="trailColor"
+              :percent="status.cpu.percent" :width="gaugeSize" />
             <div>
               <b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
               <a-tooltip>
@@ -46,8 +46,8 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
           </a-col>
 
           <a-col :span="12" class="text-center">
-            <a-progress type="dashboard" status="normal" :stroke-color="status.mem.color"
-              :trail-color="trailColor" :percent="status.mem.percent" :width="gaugeSize" />
+            <a-progress type="dashboard" status="normal" :stroke-color="status.mem.color" :trail-color="trailColor"
+              :percent="status.mem.percent" :width="gaugeSize" />
             <div>
               <b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
               {{ SizeFormatter.sizeFormat(status.mem.total) }}
@@ -60,8 +60,8 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
       <a-col :xs="24" :md="12">
         <a-row>
           <a-col :span="12" class="text-center">
-            <a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"
-              :trail-color="trailColor" :percent="status.swap.percent" :width="gaugeSize" />
+            <a-progress type="dashboard" status="normal" :stroke-color="status.swap.color" :trail-color="trailColor"
+              :percent="status.swap.percent" :width="gaugeSize" />
             <div>
               <b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
               {{ SizeFormatter.sizeFormat(status.swap.total) }}
@@ -69,8 +69,8 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
           </a-col>
 
           <a-col :span="12" class="text-center">
-            <a-progress type="dashboard" status="normal" :stroke-color="status.disk.color"
-              :trail-color="trailColor" :percent="status.disk.percent" :width="gaugeSize" />
+            <a-progress type="dashboard" status="normal" :stroke-color="status.disk.color" :trail-color="trailColor"
+              :percent="status.disk.percent" :width="gaugeSize" />
             <div>
               <b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
               {{ SizeFormatter.sizeFormat(status.disk.total) }}

+ 3 - 6
frontend/src/pages/index/SystemHistoryModal.vue

@@ -130,12 +130,9 @@ watch([activeKey, bucket], () => {
       <div class="cpu-chart-meta">
         Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
       </div>
-      <Sparkline :data="points" :labels="labels" :vb-width="840" :height="220"
-        :stroke="strokeColor" :stroke-width="2.2"
-        :show-grid="true" :show-axes="true" :tick-count-x="5"
-        :max-points="points.length || 1"
-        :fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true"
-        :value-min="0" :value-max="activeMetric?.valueMax ?? null"
+      <Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
+        :show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
+        :marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="activeMetric?.valueMax ?? null"
         :y-formatter="yFormatter" />
     </div>
   </a-modal>

+ 33 - 7
frontend/src/pages/index/XrayLogModal.vue

@@ -161,7 +161,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
       <table v-else class="xraylog-table">
         <thead>
           <tr>
-            <th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>
+            <th>Date</th>
+            <th>From</th>
+            <th>To</th>
+            <th>Inbound</th>
+            <th>Outbound</th>
+            <th>Email</th>
           </tr>
         </thead>
         <tbody>
@@ -190,9 +195,11 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   flex-wrap: wrap;
   row-gap: 8px;
 }
+
 .log-toolbar .filter-item {
   flex: 1 1 160px;
 }
+
 .log-toolbar .download-item {
   margin-left: auto;
 }
@@ -201,7 +208,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   /* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
      below so blocked/proxy rows keep ≥4.5:1 contrast on darker surfaces. */
   --log-blocked: #e04141;
-  --log-proxy:   #3c89e8;
+  --log-proxy: #3c89e8;
   --log-divider: rgba(128, 128, 128, 0.18);
 
   margin-top: 12px;
@@ -215,6 +222,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   border-radius: 6px;
   background: rgba(0, 0, 0, 0.04);
 }
+
 .log-container-mobile {
   padding: 8px;
   font-size: 12px;
@@ -231,7 +239,10 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   border-bottom: 1px solid var(--log-divider);
   padding: 8px 0;
 }
-.log-card:last-child { border-bottom: 0; }
+
+.log-card:last-child {
+  border-bottom: 0;
+}
 
 .log-card-head {
   display: flex;
@@ -240,11 +251,13 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   gap: 8px;
   margin-bottom: 4px;
 }
+
 .log-time {
   font-weight: 600;
   font-size: 12px;
   letter-spacing: 0.02em;
 }
+
 .log-event-tag {
   margin: 0;
   font-size: 10px;
@@ -260,9 +273,11 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   font-size: 12px;
   margin-bottom: 4px;
 }
+
 .log-addr {
   word-break: break-all;
 }
+
 .log-arrow {
   opacity: 0.5;
 }
@@ -274,12 +289,14 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   font-size: 11px;
   opacity: 0.75;
 }
+
 .log-meta-pair {
   display: inline-flex;
   align-items: baseline;
   gap: 4px;
   word-break: break-all;
 }
+
 .log-meta-key {
   font-size: 10px;
   text-transform: uppercase;
@@ -293,13 +310,13 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   color: rgba(255, 255, 255, 0.88);
 
   --log-blocked: #ff7575;
-  --log-proxy:   #6aa6ee;
+  --log-proxy: #6aa6ee;
   --log-divider: rgba(255, 255, 255, 0.1);
 }
 
 :global([data-theme="ultra-dark"]) .log-container {
   --log-blocked: #ff8a8a;
-  --log-proxy:   #7fb6f1;
+  --log-proxy: #7fb6f1;
   --log-divider: rgba(255, 255, 255, 0.12);
 }
 
@@ -309,10 +326,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   padding-bottom: 0 !important;
   max-width: 100vw !important;
 }
+
 :global(.xraylog-modal-mobile .ant-modal-content) {
   border-radius: 0;
   height: 100vh;
 }
+
 :global(.xraylog-modal-mobile .ant-modal-body) {
   padding: 12px;
 }
@@ -321,11 +340,18 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
   border-collapse: collapse;
   width: 100%;
 }
+
 .xraylog-table td,
 .xraylog-table th {
   padding: 2px 15px;
   text-align: left;
 }
-.xraylog-table .log-row-1 { color: var(--log-blocked); }
-.xraylog-table .log-row-2 { color: var(--log-proxy); }
+
+.xraylog-table .log-row-1 {
+  color: var(--log-blocked);
+}
+
+.xraylog-table .log-row-2 {
+  color: var(--log-proxy);
+}
 </style>

+ 7 - 28
frontend/src/pages/nodes/NodeFormModal.vue

@@ -111,17 +111,8 @@ async function onSave() {
 </script>
 
 <template>
-  <a-modal
-    :open="open"
-    :title="title"
-    :confirm-loading="submitting"
-    :ok-text="t('save')"
-    :cancel-text="t('cancel')"
-    :mask-closable="false"
-    width="640px"
-    @ok="onSave"
-    @cancel="close"
-  >
+  <a-modal :open="open" :title="title" :confirm-loading="submitting" :ok-text="t('save')" :cancel-text="t('cancel')"
+    :mask-closable="false" width="640px" @ok="onSave" @cancel="close">
     <a-form layout="vertical" :model="form">
       <a-row :gutter="16">
         <a-col :span="12">
@@ -171,10 +162,7 @@ async function onSave() {
       </a-row>
 
       <a-form-item :label="t('pages.nodes.apiToken')" required>
-        <a-input-password
-          v-model:value="form.apiToken"
-          :placeholder="t('pages.nodes.apiTokenPlaceholder')"
-        />
+        <a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
         <div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
       </a-form-item>
 
@@ -183,20 +171,11 @@ async function onSave() {
           {{ t('pages.nodes.testConnection') }}
         </a-button>
         <div v-if="testResult" class="test-result">
-          <a-alert
-            v-if="testResult.status === 'online'"
-            type="success"
-            show-icon
+          <a-alert v-if="testResult.status === 'online'" type="success" show-icon
             :message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
-            :description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined"
-          />
-          <a-alert
-            v-else
-            type="error"
-            show-icon
-            :message="t('pages.nodes.connectionFailed')"
-            :description="testResult.error"
-          />
+            :description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined" />
+          <a-alert v-else type="error" show-icon :message="t('pages.nodes.connectionFailed')"
+            :description="testResult.error" />
         </div>
       </div>
     </a-form>

+ 6 - 24
frontend/src/pages/nodes/NodeHistoryPanel.vue

@@ -79,33 +79,15 @@ watch(() => props.node?.id, (a, b) => {
   <div class="node-history-panel">
     <div class="series">
       <div class="series-title">{{ t('pages.nodes.cpu') }}</div>
-      <Sparkline
-        :data="cpuPoints"
-        :labels="cpuLabels"
-        :vb-width="640" :height="120"
-        stroke="#008771"
-        :show-grid="true" :show-axes="true"
-        :tick-count-x="4"
-        :max-points="cpuPoints.length || 1"
-        :fill-opacity="0.18"
-        :marker-radius="2.6"
-        :show-tooltip="true"
-      />
+      <Sparkline :data="cpuPoints" :labels="cpuLabels" :vb-width="640" :height="120" stroke="#008771" :show-grid="true"
+        :show-axes="true" :tick-count-x="4" :max-points="cpuPoints.length || 1" :fill-opacity="0.18"
+        :marker-radius="2.6" :show-tooltip="true" />
     </div>
     <div class="series">
       <div class="series-title">{{ t('pages.nodes.mem') }}</div>
-      <Sparkline
-        :data="memPoints"
-        :labels="memLabels"
-        :vb-width="640" :height="120"
-        stroke="#7c4dff"
-        :show-grid="true" :show-axes="true"
-        :tick-count-x="4"
-        :max-points="memPoints.length || 1"
-        :fill-opacity="0.18"
-        :marker-radius="2.6"
-        :show-tooltip="true"
-      />
+      <Sparkline :data="memPoints" :labels="memLabels" :vb-width="640" :height="120" stroke="#7c4dff" :show-grid="true"
+        :show-axes="true" :tick-count-x="4" :max-points="memPoints.length || 1" :fill-opacity="0.18"
+        :marker-radius="2.6" :show-tooltip="true" />
     </div>
   </div>
 </template>

+ 17 - 18
frontend/src/pages/nodes/NodeList.vue

@@ -76,19 +76,15 @@ function formatPct(p) {
   <a-card size="small" hoverable>
     <div class="toolbar">
       <a-button type="primary" @click="emit('add')">
-        <template #icon><PlusOutlined /></template>
+        <template #icon>
+          <PlusOutlined />
+        </template>
         {{ t('pages.nodes.addNode') }}
       </a-button>
     </div>
 
-    <a-table
-      :data-source="dataSource"
-      :pagination="false"
-      :loading="loading"
-      :scroll="{ x: 'max-content' }"
-      size="middle"
-      row-key="id"
-    >
+    <a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
+      size="middle" row-key="id">
       <template #expandedRowRender="{ record }">
         <NodeHistoryPanel :node="record" />
       </template>
@@ -110,7 +106,8 @@ function formatPct(p) {
       <a-table-column :title="t('pages.nodes.status')" data-index="status" align="center">
         <template #default="{ record }">
           <a-space :size="4">
-            <a-badge :status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
+            <a-badge
+              :status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
             <span>{{ t(`pages.nodes.statusValues.${record.status || 'unknown'}`) }}</span>
             <a-tooltip v-if="record.lastError" :title="record.lastError">
               <ExclamationCircleOutlined style="color: #faad14" />
@@ -150,11 +147,7 @@ function formatPct(p) {
 
       <a-table-column :title="t('pages.nodes.enable')" data-index="enable" align="center" :width="80">
         <template #default="{ record }">
-          <a-switch
-            :checked="record.enable"
-            size="small"
-            @change="(v) => emit('toggle-enable', record, v)"
-          />
+          <a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
         </template>
       </a-table-column>
 
@@ -163,17 +156,23 @@ function formatPct(p) {
           <a-space>
             <a-tooltip :title="t('pages.nodes.probe')">
               <a-button type="text" size="small" @click="emit('probe', record)">
-                <template #icon><ThunderboltOutlined /></template>
+                <template #icon>
+                  <ThunderboltOutlined />
+                </template>
               </a-button>
             </a-tooltip>
             <a-tooltip :title="t('edit')">
               <a-button type="text" size="small" @click="emit('edit', record)">
-                <template #icon><EditOutlined /></template>
+                <template #icon>
+                  <EditOutlined />
+                </template>
               </a-button>
             </a-tooltip>
             <a-tooltip :title="t('delete')">
               <a-button type="text" size="small" danger @click="emit('delete', record)">
-                <template #icon><DeleteOutlined /></template>
+                <template #icon>
+                  <DeleteOutlined />
+                </template>
               </a-button>
             </a-tooltip>
           </a-space>

+ 10 - 37
frontend/src/pages/nodes/NodesPage.vue

@@ -100,10 +100,7 @@ async function onToggleEnable(node, next) {
 
 <template>
   <a-config-provider :theme="antdThemeConfig">
-    <a-layout
-      class="nodes-page"
-      :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
-    >
+    <a-layout class="nodes-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
       <AppSidebar :base-path="basePath" :request-uri="requestUri" />
 
       <a-layout class="content-shell">
@@ -117,40 +114,29 @@ async function onToggleEnable(node, next) {
                 <a-card size="small" hoverable class="summary-card">
                   <a-row :gutter="[16, 12]">
                     <a-col :sm="12" :md="6">
-                      <CustomStatistic
-                        :title="t('pages.nodes.totalNodes')"
-                        :value="String(totals.total)"
-                      >
+                      <CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
                         <template #prefix>
                           <CloudServerOutlined />
                         </template>
                       </CustomStatistic>
                     </a-col>
                     <a-col :sm="12" :md="6">
-                      <CustomStatistic
-                        :title="t('pages.nodes.onlineNodes')"
-                        :value="String(totals.online)"
-                      >
+                      <CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
                         <template #prefix>
                           <CheckCircleOutlined style="color: #52c41a" />
                         </template>
                       </CustomStatistic>
                     </a-col>
                     <a-col :sm="12" :md="6">
-                      <CustomStatistic
-                        :title="t('pages.nodes.offlineNodes')"
-                        :value="String(totals.offline)"
-                      >
+                      <CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
                         <template #prefix>
                           <CloseCircleOutlined style="color: #ff4d4f" />
                         </template>
                       </CustomStatistic>
                     </a-col>
                     <a-col :sm="12" :md="6">
-                      <CustomStatistic
-                        :title="t('pages.nodes.avgLatency')"
-                        :value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'"
-                      >
+                      <CustomStatistic :title="t('pages.nodes.avgLatency')"
+                        :value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
                         <template #prefix>
                           <ThunderboltOutlined />
                         </template>
@@ -162,29 +148,16 @@ async function onToggleEnable(node, next) {
 
               <!-- Node table -->
               <a-col :span="24">
-                <NodeList
-                  :nodes="nodes"
-                  :loading="loading"
-                  :is-mobile="isMobile"
-                  @add="onAdd"
-                  @edit="onEdit"
-                  @delete="onDelete"
-                  @probe="onProbe"
-                  @toggle-enable="onToggleEnable"
-                />
+                <NodeList :nodes="nodes" :loading="loading" :is-mobile="isMobile" @add="onAdd" @edit="onEdit"
+                  @delete="onDelete" @probe="onProbe" @toggle-enable="onToggleEnable" />
               </a-col>
             </a-row>
           </a-spin>
         </a-layout-content>
       </a-layout>
 
-      <NodeFormModal
-        v-model:open="formOpen"
-        :mode="formMode"
-        :node="formNode"
-        :test-connection="testConnection"
-        :save="onSave"
-      />
+      <NodeFormModal v-model:open="formOpen" :mode="formMode" :node="formNode" :test-connection="testConnection"
+        :save="onSave" />
     </a-layout>
   </a-config-provider>
 </template>

+ 1 - 6
frontend/src/pages/settings/SecurityTab.vue

@@ -221,12 +221,7 @@ function toggleTwoFactor() {
         <template #title>{{ t('pages.nodes.apiToken') }}</template>
         <template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
         <template #control>
-          <a-input-password
-            :value="apiToken"
-            readonly
-            :loading="apiTokenLoading"
-            style="min-width: 240px"
-          />
+          <a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" />
         </template>
       </SettingListItem>
       <a-list-item>

+ 38 - 34
frontend/src/pages/sub/SubPage.vue

@@ -163,11 +163,8 @@ const themeClass = computed(() => ({
                       <ThemeSwitchLogin />
                       <span>{{ t('pages.settings.language') }}</span>
                       <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
-                        <a-select-option
-                          v-for="l in LanguageManager.supportedLanguages"
-                          :key="l.value"
-                          :value="l.value"
-                        >
+                        <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
+                          :value="l.value">
                           <span :aria-label="l.name">{{ l.icon }}</span>
                           &nbsp;&nbsp;<span>{{ l.name }}</span>
                         </a-select-option>
@@ -175,7 +172,9 @@ const themeClass = computed(() => ({
                     </a-space>
                   </template>
                   <a-button shape="circle">
-                    <template #icon><SettingOutlined /></template>
+                    <template #icon>
+                      <SettingOutlined />
+                    </template>
                   </a-button>
                 </a-popover>
               </template>
@@ -185,12 +184,7 @@ const themeClass = computed(() => ({
                 <a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
                   <div class="qr-box">
                     <a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
-                    <canvas
-                      ref="subQr"
-                      class="qr-canvas"
-                      :title="t('copy')"
-                      @click="copy(subUrl)"
-                    />
+                    <canvas ref="subQr" class="qr-canvas" :title="t('copy')" @click="copy(subUrl)" />
                   </div>
                 </a-col>
                 <a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
@@ -198,23 +192,13 @@ const themeClass = computed(() => ({
                     <a-tag color="purple" class="qr-tag">
                       {{ t('pages.settings.subSettings') }} JSON
                     </a-tag>
-                    <canvas
-                      ref="subJsonQr"
-                      class="qr-canvas"
-                      :title="t('copy')"
-                      @click="copy(subJsonUrl)"
-                    />
+                    <canvas ref="subJsonQr" class="qr-canvas" :title="t('copy')" @click="copy(subJsonUrl)" />
                   </div>
                 </a-col>
                 <a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
                   <div class="qr-box">
                     <a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
-                    <canvas
-                      ref="subClashQr"
-                      class="qr-canvas"
-                      :title="t('copy')"
-                      @click="copy(subClashUrl)"
-                    />
+                    <canvas ref="subClashQr" class="qr-canvas" :title="t('copy')" @click="copy(subClashUrl)" />
                   </div>
                 </a-col>
               </a-row>
@@ -248,12 +232,7 @@ const themeClass = computed(() => ({
 
               <!-- ============== Individual links ============== -->
               <div v-if="links.length" class="links-section">
-                <div
-                  v-for="(link, idx) in links"
-                  :key="link"
-                  class="link-row"
-                  @click="copy(link)"
-                >
+                <div v-for="(link, idx) in links" :key="link" class="link-row" @click="copy(link)">
                   <a-tag color="purple" class="link-tag">{{ linkName(link, idx) }}</a-tag>
                   <div class="link-box">
                     <CopyOutlined class="link-copy-icon" />
@@ -267,12 +246,15 @@ const themeClass = computed(() => ({
                 <a-col :xs="24" :sm="12" class="app-col">
                   <a-dropdown :trigger="['click']">
                     <a-button :block="isMobile" size="large" type="primary">
-                      <AndroidOutlined /> Android <DownOutlined />
+                      <AndroidOutlined /> Android
+                      <DownOutlined />
                     </a-button>
                     <template #overlay>
                       <a-menu>
-                        <a-menu-item key="android-v2box" @click="open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`)">V2Box</a-menu-item>
-                        <a-menu-item key="android-v2rayng" @click="open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`)">V2RayNG</a-menu-item>
+                        <a-menu-item key="android-v2box"
+                          @click="open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`)">V2Box</a-menu-item>
+                        <a-menu-item key="android-v2rayng"
+                          @click="open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`)">V2RayNG</a-menu-item>
                         <a-menu-item key="android-singbox" @click="copy(subUrl)">Sing-box</a-menu-item>
                         <a-menu-item key="android-v2raytun" @click="copy(subUrl)">V2RayTun</a-menu-item>
                         <a-menu-item key="android-npvtunnel" @click="copy(subUrl)">NPV Tunnel</a-menu-item>
@@ -284,7 +266,8 @@ const themeClass = computed(() => ({
                 <a-col :xs="24" :sm="12" class="app-col">
                   <a-dropdown :trigger="['click']">
                     <a-button :block="isMobile" size="large" type="primary">
-                      <AppleOutlined /> iOS <DownOutlined />
+                      <AppleOutlined /> iOS
+                      <DownOutlined />
                     </a-button>
                     <template #overlay>
                       <a-menu>
@@ -314,14 +297,17 @@ const themeClass = computed(() => ({
   min-height: 100vh;
   background: var(--bg-page);
 }
+
 .subscription-page.is-dark {
   --bg-page: #0a1222;
   --bg-card: #151f31;
 }
+
 .subscription-page.is-dark.is-ultra {
   --bg-page: #050505;
   --bg-card: #0c0e12;
 }
+
 .subscription-page :deep(.ant-layout),
 .subscription-page :deep(.ant-layout-content) {
   background: transparent;
@@ -339,10 +325,12 @@ const themeClass = computed(() => ({
 .qr-row {
   margin-bottom: 12px;
 }
+
 .qr-col {
   display: flex;
   justify-content: center;
 }
+
 .qr-box {
   display: inline-flex;
   flex-direction: column;
@@ -350,11 +338,13 @@ const themeClass = computed(() => ({
   gap: 4px;
   width: 220px;
 }
+
 .qr-tag {
   width: 100%;
   text-align: center;
   margin: 0;
 }
+
 .qr-canvas {
   cursor: pointer;
   background: #fff;
@@ -370,16 +360,19 @@ const themeClass = computed(() => ({
 .info-table {
   margin-top: 12px;
 }
+
 .info-table :deep(.ant-descriptions-view),
 .info-table :deep(.ant-descriptions-view) table,
 .info-table :deep(.ant-descriptions-view) th,
 .info-table :deep(.ant-descriptions-view) td {
   border-color: rgba(0, 0, 0, 0.18) !important;
 }
+
 .info-table :deep(tbody > tr > th),
 .info-table :deep(tbody > tr > td) {
   border-bottom: 1px solid rgba(0, 0, 0, 0.18) !important;
 }
+
 .info-table :deep(tbody > tr:last-child > th),
 .info-table :deep(tbody > tr:last-child > td) {
   border-bottom: none !important;
@@ -391,10 +384,12 @@ const themeClass = computed(() => ({
 .is-dark .info-table :deep(.ant-descriptions-view) td {
   border-color: rgba(255, 255, 255, 0.18) !important;
 }
+
 .is-dark .info-table :deep(tbody > tr > th),
 .is-dark .info-table :deep(tbody > tr > td) {
   border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important;
 }
+
 .is-dark .info-table :deep(tbody > tr:last-child > th),
 .is-dark .info-table :deep(tbody > tr:last-child > td) {
   border-bottom: none !important;
@@ -404,17 +399,20 @@ const themeClass = computed(() => ({
 .links-section {
   margin-top: 16px;
 }
+
 .link-row {
   position: relative;
   margin-bottom: 16px;
   text-align: center;
 }
+
 .link-tag {
   margin-bottom: -10px;
   position: relative;
   z-index: 2;
   box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
 }
+
 .link-box {
   cursor: pointer;
   border-radius: 12px;
@@ -430,19 +428,23 @@ const themeClass = computed(() => ({
   background: rgba(0, 0, 0, 0.03);
   border: 1px solid rgba(0, 0, 0, 0.08);
 }
+
 .link-box:hover {
   background: rgba(0, 0, 0, 0.05);
   border-color: rgba(0, 0, 0, 0.14);
 }
+
 .link-copy-icon {
   margin-right: 6px;
   opacity: 0.6;
 }
+
 .is-dark .link-box {
   background: rgba(0, 0, 0, 0.2);
   border-color: rgba(255, 255, 255, 0.1);
   color: rgba(255, 255, 255, 0.85);
 }
+
 .is-dark .link-box:hover {
   background: rgba(0, 0, 0, 0.3);
   border-color: rgba(255, 255, 255, 0.2);
@@ -452,6 +454,7 @@ const themeClass = computed(() => ({
 .apps-row {
   margin-top: 24px;
 }
+
 .app-col {
   text-align: center;
 }
@@ -459,6 +462,7 @@ const themeClass = computed(() => ({
 .settings-popover {
   min-width: 220px;
 }
+
 .lang-select {
   width: 100%;
 }

+ 2 - 12
frontend/src/pages/xray/BalancerFormModal.vue

@@ -95,12 +95,7 @@ const okText = computed(() =>
   <a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')"
     :ok-button-props="{ disabled: !isValid }" :mask-closable="false" @ok="onOk" @cancel="close">
     <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
-      <a-form-item
-        label="Tag"
-        :validate-status="tagValidateStatus"
-        :help="tagHelp"
-        has-feedback
-      >
+      <a-form-item label="Tag" :validate-status="tagValidateStatus" :help="tagHelp" has-feedback>
         <a-input v-model:value="form.tag" placeholder="unique balancer tag" />
       </a-form-item>
 
@@ -110,12 +105,7 @@ const okText = computed(() =>
         </a-select>
       </a-form-item>
 
-      <a-form-item
-        label="Selector"
-        :validate-status="selectorValidateStatus"
-        :help="selectorHelp"
-        has-feedback
-      >
+      <a-form-item label="Selector" :validate-status="selectorValidateStatus" :help="selectorHelp" has-feedback>
         <a-select v-model:value="form.selector" mode="tags" :token-separators="[',']">
           <a-select-option v-for="tag in outboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
         </a-select>

+ 103 - 0
frontend/src/pages/xray/DnsPresetsModal.vue

@@ -0,0 +1,103 @@
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:open', 'install']);
+
+const PRESETS = [
+  {
+    name: 'Google DNS',
+    family: false,
+    data: [
+      '8.8.8.8',
+      '8.8.4.4',
+      '2001:4860:4860::8888',
+      '2001:4860:4860::8844',
+    ],
+  },
+  {
+    name: 'Cloudflare DNS',
+    family: false,
+    data: [
+      '1.1.1.1',
+      '1.0.0.1',
+      '2606:4700:4700::1111',
+      '2606:4700:4700::1001',
+    ],
+  },
+  {
+    name: 'AdGuard DNS',
+    family: false,
+    data: [
+      '94.140.14.14',
+      '94.140.15.15',
+      '2a10:50c0::ad1:ff',
+      '2a10:50c0::ad2:ff',
+    ],
+  },
+  {
+    name: 'AdGuard Family DNS',
+    family: true,
+    data: [
+      '94.140.14.15',
+      '94.140.15.16',
+      '2a10:50c0::bad1:ff',
+      '2a10:50c0::bad2:ff',
+    ],
+  },
+  {
+    name: 'Cloudflare Family DNS',
+    family: true,
+    data: [
+      '1.1.1.3',
+      '1.0.0.3',
+      '2606:4700:4700::1113',
+      '2606:4700:4700::1003',
+    ],
+  },
+];
+
+const title = computed(() => t('pages.xray.dns.dnsPresetTitle'));
+
+function close() { emit('update:open', false); }
+function install(preset) {
+  emit('install', [...preset.data]);
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="title" :footer="null" :mask-closable="false" @cancel="close">
+    <a-list bordered>
+      <a-list-item v-for="preset in PRESETS" :key="preset.name" class="preset-row">
+        <a-space size="small" align="center">
+          <a-tag :color="preset.family ? 'purple' : 'green'">
+            {{ preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS' }}
+          </a-tag>
+          <span class="preset-name">{{ preset.name }}</span>
+        </a-space>
+        <a-button type="primary" size="small" @click="install(preset)">
+          {{ t('install') }}
+        </a-button>
+      </a-list-item>
+    </a-list>
+  </a-modal>
+</template>
+
+<style scoped>
+.preset-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.preset-name {
+  font-weight: 500;
+}
+</style>

+ 71 - 49
frontend/src/pages/xray/DnsServerModal.vue

@@ -5,11 +5,6 @@ import { PlusOutlined, MinusOutlined } from '@ant-design/icons-vue';
 
 const { t } = useI18n();
 
-// DNS server add/edit modal — mirrors web/html/modals/xray_dns_modal.html.
-// The legacy panel allowed both string-form ("8.8.8.8") and object-form
-// servers; we always edit as an object and the parent can decide
-// whether to collapse to a string when nothing besides address is set.
-
 const props = defineProps({
   open: { type: Boolean, default: false },
   server: { type: [Object, String, null], default: null },
@@ -22,12 +17,17 @@ const DEFAULT_SERVER = () => ({
   address: 'localhost',
   port: 53,
   domains: [],
-  expectIPs: [],
+  expectedIPs: [],
   unexpectedIPs: [],
   queryStrategy: 'UseIP',
-  skipFallback: true,
+  skipFallback: false,
   disableCache: false,
   finalQuery: false,
+  tag: '',
+  clientIP: '',
+  serveStale: false,
+  serveExpiredTTL: 0,
+  timeoutMs: 4000,
 });
 
 const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
@@ -42,45 +42,53 @@ watch(() => props.open, (next) => {
     form.address = props.server;
     return;
   }
-  // Object — copy fields, defaulting missing arrays to empty.
+  const incoming = props.server;
   Object.assign(form, {
     ...DEFAULT_SERVER(),
-    ...props.server,
-    domains: [...(props.server.domains || [])],
-    expectIPs: [...(props.server.expectIPs || [])],
-    unexpectedIPs: [...(props.server.unexpectedIPs || [])],
+    ...incoming,
+    domains: [...(incoming.domains || [])],
+    expectedIPs: [...(incoming.expectedIPs || incoming.expectIPs || [])],
+    unexpectedIPs: [...(incoming.unexpectedIPs || [])],
   });
 });
 
 function close() { emit('update:open', false); }
 
 function onOk() {
-  // If the user only set an address (everything else default), emit a
-  // bare string — that's the wire shape the legacy panel uses for
-  // servers like "8.8.8.8" and keeps the JSON tidy.
   const isPlain = form.domains.length === 0
-    && form.expectIPs.length === 0
+    && form.expectedIPs.length === 0
     && form.unexpectedIPs.length === 0
     && form.port === 53
     && form.queryStrategy === 'UseIP'
-    && form.skipFallback === true
+    && form.skipFallback === false
     && form.disableCache === false
-    && form.finalQuery === false;
+    && form.finalQuery === false
+    && !form.tag
+    && !form.clientIP
+    && form.serveStale === false
+    && form.serveExpiredTTL === 0
+    && form.timeoutMs === 4000;
   if (isPlain) {
     emit('confirm', form.address);
-  } else {
-    emit('confirm', {
-      address: form.address,
-      port: form.port,
-      domains: [...form.domains].filter(Boolean),
-      expectIPs: [...form.expectIPs].filter(Boolean),
-      unexpectedIPs: [...form.unexpectedIPs].filter(Boolean),
-      queryStrategy: form.queryStrategy,
-      skipFallback: form.skipFallback,
-      disableCache: form.disableCache,
-      finalQuery: form.finalQuery,
-    });
+    return;
   }
+  const out = {
+    address: form.address,
+    port: form.port,
+    domains: [...form.domains].filter(Boolean),
+    expectedIPs: [...form.expectedIPs].filter(Boolean),
+    unexpectedIPs: [...form.unexpectedIPs].filter(Boolean),
+    queryStrategy: form.queryStrategy,
+    skipFallback: form.skipFallback,
+    disableCache: form.disableCache,
+    finalQuery: form.finalQuery,
+    serveStale: form.serveStale,
+    serveExpiredTTL: form.serveExpiredTTL,
+    timeoutMs: form.timeoutMs,
+  };
+  if (form.tag) out.tag = form.tag;
+  if (form.clientIP) out.clientIP = form.clientIP;
+  emit('confirm', out);
 }
 
 const title = computed(() =>
@@ -89,15 +97,8 @@ const title = computed(() =>
 </script>
 
 <template>
-  <a-modal
-    :open="open"
-    :title="title"
-    :ok-text="t('confirm')"
-    :cancel-text="t('close')"
-    :mask-closable="false"
-    @ok="onOk"
-    @cancel="close"
-  >
+  <a-modal :open="open" :title="title" :ok-text="t('confirm')" :cancel-text="t('close')" :mask-closable="false"
+    @ok="onOk" @cancel="close">
     <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
       <a-form-item :label="t('pages.inbounds.address')">
         <a-input v-model:value="form.address" />
@@ -105,17 +106,28 @@ const title = computed(() =>
       <a-form-item :label="t('pages.inbounds.port')">
         <a-input-number v-model:value="form.port" :min="1" :max="65535" />
       </a-form-item>
+      <a-form-item :label="t('pages.xray.dns.tag')">
+        <a-input v-model:value="form.tag" />
+      </a-form-item>
+      <a-form-item :label="t('pages.xray.dns.clientIp')">
+        <a-input v-model:value="form.clientIP" />
+      </a-form-item>
       <a-form-item :label="t('pages.xray.dns.strategy')">
         <a-select v-model:value="form.queryStrategy" :style="{ width: '100%' }">
           <a-select-option v-for="s in STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
         </a-select>
       </a-form-item>
+      <a-form-item :label="t('pages.xray.dns.timeoutMs')">
+        <a-input-number v-model:value="form.timeoutMs" :min="0" :step="500" />
+      </a-form-item>
 
       <a-divider :style="{ margin: '5px 0' }" />
 
       <a-form-item :label="t('pages.xray.dns.domains')">
         <a-button size="small" type="primary" @click="form.domains.push('')">
-          <template #icon><PlusOutlined /></template>
+          <template #icon>
+            <PlusOutlined />
+          </template>
         </a-button>
         <template v-for="(_, idx) in form.domains" :key="`d${idx}`">
           <a-input v-model:value="form.domains[idx]" :style="{ marginTop: '4px' }">
@@ -127,13 +139,15 @@ const title = computed(() =>
       </a-form-item>
 
       <a-form-item :label="t('pages.xray.dns.expectIPs')">
-        <a-button size="small" type="primary" @click="form.expectIPs.push('')">
-          <template #icon><PlusOutlined /></template>
+        <a-button size="small" type="primary" @click="form.expectedIPs.push('')">
+          <template #icon>
+            <PlusOutlined />
+          </template>
         </a-button>
-        <template v-for="(_, idx) in form.expectIPs" :key="`e${idx}`">
-          <a-input v-model:value="form.expectIPs[idx]" :style="{ marginTop: '4px' }">
+        <template v-for="(_, idx) in form.expectedIPs" :key="`e${idx}`">
+          <a-input v-model:value="form.expectedIPs[idx]" :style="{ marginTop: '4px' }">
             <template #addonAfter>
-              <MinusOutlined @click="form.expectIPs.splice(idx, 1)" />
+              <MinusOutlined @click="form.expectedIPs.splice(idx, 1)" />
             </template>
           </a-input>
         </template>
@@ -141,7 +155,9 @@ const title = computed(() =>
 
       <a-form-item :label="t('pages.xray.dns.unexpectIPs')">
         <a-button size="small" type="primary" @click="form.unexpectedIPs.push('')">
-          <template #icon><PlusOutlined /></template>
+          <template #icon>
+            <PlusOutlined />
+          </template>
         </a-button>
         <template v-for="(_, idx) in form.unexpectedIPs" :key="`u${idx}`">
           <a-input v-model:value="form.unexpectedIPs[idx]" :style="{ marginTop: '4px' }">
@@ -154,14 +170,20 @@ const title = computed(() =>
 
       <a-divider :style="{ margin: '5px 0' }" />
 
-      <a-form-item label="Skip fallback">
+      <a-form-item :label="t('pages.xray.dns.skipFallback')">
         <a-switch v-model:checked="form.skipFallback" />
       </a-form-item>
+      <a-form-item :label="t('pages.xray.dns.finalQuery')">
+        <a-switch v-model:checked="form.finalQuery" />
+      </a-form-item>
       <a-form-item :label="t('pages.xray.dns.disableCache')">
         <a-switch v-model:checked="form.disableCache" />
       </a-form-item>
-      <a-form-item label="Final query">
-        <a-switch v-model:checked="form.finalQuery" />
+      <a-form-item :label="t('pages.xray.dns.serveStale')">
+        <a-switch v-model:checked="form.serveStale" />
+      </a-form-item>
+      <a-form-item :label="t('pages.xray.dns.serveExpiredTTL')">
+        <a-input-number v-model:value="form.serveExpiredTTL" :min="0" :step="60" />
       </a-form-item>
     </a-form>
   </a-modal>

+ 197 - 59
frontend/src/pages/xray/DnsTab.vue

@@ -1,31 +1,27 @@
 <script setup>
-import { computed, ref } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { Modal } from 'ant-design-vue';
 import {
   PlusOutlined,
   MoreOutlined,
   EditOutlined,
   DeleteOutlined,
+  MenuOutlined,
 } from '@ant-design/icons-vue';
 
 import SettingListItem from '@/components/SettingListItem.vue';
 import DnsServerModal from './DnsServerModal.vue';
+import DnsPresetsModal from './DnsPresetsModal.vue';
 
 const { t } = useI18n();
 
-// Structured DNS editor — mirrors web/html/settings/xray/dns.html.
-// Master enable switch + general DNS options + per-server table with
-// add/edit/delete (modal flow), plus a Fake DNS table. Both lists
-// flow through templateSettings.dns / .fakedns reactively so the
-// useXraySetting composable picks every edit up via its deep watch.
-
 const props = defineProps({
   templateSettings: { type: Object, default: null },
 });
 
 const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
 
-// ============== Master toggle ==============
 const enableDNS = computed({
   get: () => !!props.templateSettings?.dns,
   set: (next) => {
@@ -40,6 +36,9 @@ const enableDNS = computed({
         disableFallbackIfMatch: false,
         useSystemHosts: false,
         enableParallelQuery: false,
+        serveStale: false,
+        serveExpiredTTL: 0,
+        hosts: {},
         servers: [],
       };
       props.templateSettings.fakedns = null;
@@ -50,7 +49,6 @@ const enableDNS = computed({
   },
 });
 
-// ============== Field bridges ==============
 function dnsField(field, fallback) {
   return computed({
     get: () => props.templateSettings?.dns?.[field] ?? fallback,
@@ -68,8 +66,53 @@ const dnsDisableFallback = dnsField('disableFallback', false);
 const dnsDisableFallbackIfMatch = dnsField('disableFallbackIfMatch', false);
 const dnsEnableParallelQuery = dnsField('enableParallelQuery', false);
 const dnsUseSystemHosts = dnsField('useSystemHosts', false);
+const dnsServeStale = dnsField('serveStale', false);
+const dnsServeExpiredTTL = dnsField('serveExpiredTTL', 0);
+
+const hostsList = ref([]);
+
+function hydrateHostsFromBackend() {
+  const src = props.templateSettings?.dns?.hosts || {};
+  hostsList.value = Object.entries(src).map(([domain, val]) => ({
+    domain,
+    values: Array.isArray(val) ? [...val] : [String(val)],
+  }));
+}
+
+function syncHostsToBackend() {
+  if (!props.templateSettings?.dns) return;
+  const obj = {};
+  for (const row of hostsList.value) {
+    if (!row.domain) continue;
+    const vals = (row.values || []).filter(Boolean);
+    if (vals.length === 0) continue;
+    obj[row.domain] = vals.length === 1 ? vals[0] : vals;
+  }
+  if (Object.keys(obj).length > 0) {
+    props.templateSettings.dns.hosts = obj;
+  } else if ('hosts' in props.templateSettings.dns) {
+    delete props.templateSettings.dns.hosts;
+  }
+}
+
+watch(
+  () => !!props.templateSettings?.dns,
+  (enabled) => {
+    if (enabled) hydrateHostsFromBackend();
+    else hostsList.value = [];
+  },
+  { immediate: true },
+);
+
+watch(hostsList, syncHostsToBackend, { deep: true });
+
+function addHost() {
+  hostsList.value.push({ domain: '', values: [] });
+}
+function deleteHost(idx) {
+  hostsList.value.splice(idx, 1);
+}
 
-// ============== DNS server table ==============
 const dnsServers = computed(() => {
   const list = props.templateSettings?.dns?.servers || [];
   return list.map((s, idx) => ({ key: idx, server: s }));
@@ -79,7 +122,7 @@ const dnsColumns = computed(() => [
   { title: '#', key: 'action', align: 'center', width: 60 },
   { title: t('pages.inbounds.address'), key: 'address', align: 'left' },
   { title: t('pages.xray.dns.domains'), key: 'domains', align: 'left' },
-  { title: t('pages.xray.dns.expectIPs'), key: 'expectIPs', align: 'left' },
+  { title: t('pages.xray.dns.expectIPs'), key: 'expectedIPs', align: 'left' },
 ]);
 
 function addrFor(server) {
@@ -88,8 +131,10 @@ function addrFor(server) {
 function domainsFor(server) {
   return typeof server === 'object' ? (server.domains || []).join(',') : '';
 }
-function expectIPsFor(server) {
-  return typeof server === 'object' ? (server.expectIPs || []).join(',') : '';
+function expectedIPsFor(server) {
+  if (typeof server !== 'object' || !server) return '';
+  const list = server.expectedIPs || server.expectIPs || [];
+  return Array.isArray(list) ? list.join(',') : '';
 }
 
 // ============== Server modal ==============
@@ -122,6 +167,27 @@ function onServerConfirm(value) {
 function deleteServer(idx) {
   props.templateSettings.dns.servers.splice(idx, 1);
 }
+function clearAllServers() {
+  if (!props.templateSettings?.dns) return;
+  Modal.confirm({
+    title: t('pages.xray.dns.clearAllTitle'),
+    content: t('pages.xray.dns.clearAllConfirm'),
+    okText: t('delete'),
+    okButtonProps: { danger: true },
+    cancelText: t('cancel'),
+    onOk() {
+      props.templateSettings.dns.servers = [];
+    },
+  });
+}
+
+const presetsModalOpen = ref(false);
+function openPresets() { presetsModalOpen.value = true; }
+function onPresetInstall(serverList) {
+  if (!props.templateSettings?.dns) return;
+  props.templateSettings.dns.servers = serverList;
+  presetsModalOpen.value = false;
+}
 
 // ============== Fake DNS table ==============
 const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
@@ -239,32 +305,102 @@ function updateFakednsField(idx, field, value) {
             <a-switch v-model:checked="dnsUseSystemHosts" />
           </template>
         </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.serveStale') }}</template>
+          <template #description>{{ t('pages.xray.dns.serveStaleDesc') }}</template>
+          <template #control>
+            <a-switch v-model:checked="dnsServeStale" />
+          </template>
+        </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.serveExpiredTTL') }}</template>
+          <template #description>{{ t('pages.xray.dns.serveExpiredTTLDesc') }}</template>
+          <template #control>
+            <a-input-number v-model:value="dnsServeExpiredTTL" :min="0" :step="60" :style="{ width: '100%' }" />
+          </template>
+        </SettingListItem>
       </template>
     </a-collapse-panel>
 
-    <!-- ============== DNS servers ============== -->
-    <a-collapse-panel v-if="enableDNS" key="2" header="DNS">
-      <a-empty v-if="dnsServers.length === 0" :description="t('emptyDnsDesc')">
-        <a-button type="primary" @click="openAddServer">
-          <template #icon><PlusOutlined /></template>
-          {{ t('pages.xray.dns.add') }}
+    <!-- ============== Hosts ============== -->
+    <a-collapse-panel v-if="enableDNS" key="hosts" :header="t('pages.xray.dns.hosts')">
+      <a-empty v-if="hostsList.length === 0" :description="t('pages.xray.dns.hostsEmpty')">
+        <a-button type="primary" @click="addHost">
+          <template #icon>
+            <PlusOutlined />
+          </template>
+          {{ t('pages.xray.dns.hostsAdd') }}
         </a-button>
       </a-empty>
 
       <template v-else>
         <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
+          <a-button type="primary" @click="addHost">
+            <template #icon>
+              <PlusOutlined />
+            </template>
+            {{ t('pages.xray.dns.hostsAdd') }}
+          </a-button>
+          <div v-for="(row, idx) in hostsList" :key="`h${idx}`" class="hosts-row">
+            <a-input v-model:value="row.domain" :placeholder="t('pages.xray.dns.hostsDomain')"
+              :style="{ flex: '1 1 220px' }" />
+            <a-select v-model:value="row.values" mode="tags" :placeholder="t('pages.xray.dns.hostsValues')"
+              :style="{ flex: '2 1 320px' }" :token-separators="[',', ' ']" />
+            <a-button danger @click="deleteHost(idx)">
+              <template #icon>
+                <DeleteOutlined />
+              </template>
+            </a-button>
+          </div>
+        </a-space>
+      </template>
+    </a-collapse-panel>
+
+    <!-- ============== DNS servers ============== -->
+    <a-collapse-panel v-if="enableDNS" key="2" header="DNS">
+      <a-empty v-if="dnsServers.length === 0" :description="t('emptyDnsDesc')">
+        <a-space>
           <a-button type="primary" @click="openAddServer">
-            <template #icon><PlusOutlined /></template>
+            <template #icon>
+              <PlusOutlined />
+            </template>
             {{ t('pages.xray.dns.add') }}
           </a-button>
-          <a-table
-            :columns="dnsColumns"
-            :data-source="dnsServers"
-            :row-key="(r) => r.key"
-            :pagination="false"
-            size="small"
-            bordered
-          >
+          <a-button @click="openPresets">
+            <template #icon>
+              <MenuOutlined />
+            </template>
+            {{ t('pages.xray.dns.usePreset') }}
+          </a-button>
+        </a-space>
+      </a-empty>
+
+      <template v-else>
+        <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
+          <a-space wrap>
+            <a-button type="primary" @click="openAddServer">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+              {{ t('pages.xray.dns.add') }}
+            </a-button>
+            <a-button @click="openPresets">
+              <template #icon>
+                <MenuOutlined />
+              </template>
+              {{ t('pages.xray.dns.usePreset') }}
+            </a-button>
+            <a-button danger @click="clearAllServers">
+              <template #icon>
+                <DeleteOutlined />
+              </template>
+              {{ t('pages.xray.dns.clearAll') }}
+            </a-button>
+          </a-space>
+          <a-table :columns="dnsColumns" :data-source="dnsServers" :row-key="(r) => r.key" :pagination="false"
+            size="small" bordered>
             <template #bodyCell="{ column, record, index }">
               <template v-if="column.key === 'action'">
                 <a-space :size="6">
@@ -292,8 +428,8 @@ function updateFakednsField(idx, field, value) {
               <template v-else-if="column.key === 'domains'">
                 <span class="muted">{{ domainsFor(record.server) }}</span>
               </template>
-              <template v-else-if="column.key === 'expectIPs'">
-                <span class="muted">{{ expectIPsFor(record.server) }}</span>
+              <template v-else-if="column.key === 'expectedIPs'">
+                <span class="muted">{{ expectedIPsFor(record.server) }}</span>
               </template>
             </template>
           </a-table>
@@ -305,7 +441,9 @@ function updateFakednsField(idx, field, value) {
     <a-collapse-panel v-if="enableDNS" key="3" header="Fake DNS">
       <a-empty v-if="fakeDnsList.length === 0" :description="t('emptyFakeDnsDesc')">
         <a-button type="primary" @click="addFakedns">
-          <template #icon><PlusOutlined /></template>
+          <template #icon>
+            <PlusOutlined />
+          </template>
           {{ t('pages.xray.fakedns.add') }}
         </a-button>
       </a-empty>
@@ -313,17 +451,13 @@ function updateFakednsField(idx, field, value) {
       <template v-else>
         <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
           <a-button type="primary" @click="addFakedns">
-            <template #icon><PlusOutlined /></template>
+            <template #icon>
+              <PlusOutlined />
+            </template>
             {{ t('pages.xray.fakedns.add') }}
           </a-button>
-          <a-table
-            :columns="fakednsColumns"
-            :data-source="fakeDnsList"
-            :row-key="(r) => r.key"
-            :pagination="false"
-            size="small"
-            bordered
-          >
+          <a-table :columns="fakednsColumns" :data-source="fakeDnsList" :row-key="(r) => r.key" :pagination="false"
+            size="small" bordered>
             <template #bodyCell="{ column, record, index }">
               <template v-if="column.key === 'action'">
                 <a-space :size="6">
@@ -334,19 +468,12 @@ function updateFakednsField(idx, field, value) {
                 </a-space>
               </template>
               <template v-else-if="column.key === 'ipPool'">
-                <a-input
-                  :value="record.ipPool"
-                  size="small"
-                  @change="(e) => updateFakednsField(index, 'ipPool', e.target.value)"
-                />
+                <a-input :value="record.ipPool" size="small"
+                  @change="(e) => updateFakednsField(index, 'ipPool', e.target.value)" />
               </template>
               <template v-else-if="column.key === 'poolSize'">
-                <a-input-number
-                  :value="record.poolSize"
-                  :min="1"
-                  size="small"
-                  @change="(v) => updateFakednsField(index, 'poolSize', v)"
-                />
+                <a-input-number :value="record.poolSize" :min="1" size="small"
+                  @change="(v) => updateFakednsField(index, 'poolSize', v)" />
               </template>
             </template>
           </a-table>
@@ -355,12 +482,9 @@ function updateFakednsField(idx, field, value) {
     </a-collapse-panel>
   </a-collapse>
 
-  <DnsServerModal
-    v-model:open="serverModalOpen"
-    :server="editingServer"
-    :is-edit="editingIndex != null"
-    @confirm="onServerConfirm"
-  />
+  <DnsServerModal v-model:open="serverModalOpen" :server="editingServer" :is-edit="editingIndex != null"
+    @confirm="onServerConfirm" />
+  <DnsPresetsModal v-model:open="presetsModalOpen" @install="onPresetInstall" />
 </template>
 
 <style scoped>
@@ -368,6 +492,20 @@ function updateFakednsField(idx, field, value) {
   font-weight: 500;
   opacity: 0.7;
 }
-.muted { opacity: 0.7; word-break: break-all; }
-.danger { color: #ff4d4f; }
+
+.muted {
+  opacity: 0.7;
+  word-break: break-all;
+}
+
+.danger {
+  color: #ff4d4f;
+}
+
+.hosts-row {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  flex-wrap: wrap;
+}
 </style>

+ 3 - 7
frontend/src/pages/xray/OutboundFormModal.vue

@@ -343,8 +343,7 @@ function regenerateWgKeys() {
               <a-input-number v-model:value="outbound.settings.userLevel" :min="0" :style="{ width: '100%' }" />
             </a-form-item>
             <a-form-item label="Rules">
-              <a-button size="small" type="primary"
-                @click="outbound.settings.rules.push(new Outbound.DNSRule())">
+              <a-button size="small" type="primary" @click="outbound.settings.rules.push(new Outbound.DNSRule())">
                 <template #icon>
                   <PlusOutlined />
                 </template>
@@ -955,11 +954,8 @@ function regenerateWgKeys() {
         <!-- Gated by canEnableStream() so TCP masks don't leak into
              Freedom / Blackhole / DNS / Socks / HTTP / Wireguard outbounds
              (they don't have a stream config at all). Matches legacy. -->
-        <FinalMaskForm
-          v-if="outbound.stream && outbound.canEnableStream()"
-          :stream="outbound.stream"
-          :protocol="proto"
-        />
+        <FinalMaskForm v-if="outbound.stream && outbound.canEnableStream()" :stream="outbound.stream"
+          :protocol="proto" />
       </a-tab-pane>
 
       <!-- ============================== JSON ============================== -->

+ 74 - 55
frontend/src/pages/xray/OutboundsTab.vue

@@ -180,29 +180,32 @@ const rows = computed(() => {
       <a-col :xs="24" :sm="14">
         <a-space size="small">
           <a-button type="primary" @click="openAdd">
-            <template #icon><PlusOutlined /></template>
+            <template #icon>
+              <PlusOutlined />
+            </template>
             <span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
           </a-button>
           <a-button type="primary" @click="emit('show-warp')">
-            <template #icon><CloudOutlined /></template>
+            <template #icon>
+              <CloudOutlined />
+            </template>
             WARP
           </a-button>
           <a-button type="primary" @click="emit('show-nord')">
-            <template #icon><ApiOutlined /></template>
+            <template #icon>
+              <ApiOutlined />
+            </template>
             NordVPN
           </a-button>
         </a-space>
       </a-col>
       <a-col :xs="24" :sm="10" class="toolbar-right">
-        <a-popconfirm
-          placement="topRight"
-          :ok-text="t('reset')"
-          :cancel-text="t('cancel')"
-          :title="t('pages.inbounds.resetAllTrafficContent')"
-          @confirm="emit('reset-traffic', '-alltags-')"
-        >
+        <a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
+          :title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
           <a-button>
-            <template #icon><RetweetOutlined /></template>
+            <template #icon>
+              <RetweetOutlined />
+            </template>
           </a-button>
         </a-popconfirm>
       </a-col>
@@ -220,8 +223,7 @@ const rows = computed(() => {
             </a-tooltip>
             <a-tag color="green">{{ record.protocol }}</a-tag>
             <template
-              v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)"
-            >
+              v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)">
               <a-tag>{{ record.streamSettings?.network }}</a-tag>
               <a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
                 {{ record.streamSettings.security }}
@@ -267,15 +269,11 @@ const rows = computed(() => {
               <span v-else>failed</span>
             </span>
             <LoadingOutlined v-else-if="isTesting(index)" />
-            <a-button
-              type="primary"
-              shape="circle"
-              size="small"
-              :loading="isTesting(index)"
-              :disabled="isUntestable(record) || isTesting(index)"
-              @click="emit('test', index)"
-            >
-              <template #icon><ThunderboltOutlined /></template>
+            <a-button type="primary" shape="circle" size="small" :loading="isTesting(index)"
+              :disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
+              <template #icon>
+                <ThunderboltOutlined />
+              </template>
             </a-button>
           </span>
         </div>
@@ -283,14 +281,7 @@ const rows = computed(() => {
     </template>
 
     <!-- Desktop: table -->
-    <a-table
-      v-else
-      :columns="columns"
-      :data-source="rows"
-      :row-key="(r) => r.key"
-      :pagination="false"
-      size="small"
-    >
+    <a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small">
       <template #bodyCell="{ column, record, index }">
         <template v-if="column.key === 'action'">
           <div class="action-cell">
@@ -333,8 +324,7 @@ const rows = computed(() => {
             <div class="protocol-line">
               <a-tag color="green">{{ record.protocol }}</a-tag>
               <template
-                v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)"
-              >
+                v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)">
                 <a-tag>{{ record.streamSettings?.network }}</a-tag>
                 <a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
                   {{ record.streamSettings.security }}
@@ -374,38 +364,34 @@ const rows = computed(() => {
 
         <template v-else-if="column.key === 'test'">
           <a-tooltip :title="t('check')">
-            <a-button
-              type="primary"
-              shape="circle"
-              :loading="isTesting(index)"
-              :disabled="isUntestable(record) || isTesting(index)"
-              @click="emit('test', index)"
-            >
-              <template #icon><ThunderboltOutlined /></template>
+            <a-button type="primary" shape="circle" :loading="isTesting(index)"
+              :disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
+              <template #icon>
+                <ThunderboltOutlined />
+              </template>
             </a-button>
           </a-tooltip>
         </template>
       </template>
     </a-table>
 
-    <OutboundFormModal
-      v-model:open="modalOpen"
-      :outbound="editingOutbound"
-      :existing-tags="existingTags"
-      :inbound-tags="inboundTagOptions"
-      @confirm="onConfirm"
-    />
+    <OutboundFormModal v-model:open="modalOpen" :outbound="editingOutbound" :existing-tags="existingTags"
+      :inbound-tags="inboundTagOptions" @confirm="onConfirm" />
   </a-space>
 </template>
 
 <style scoped>
-.toolbar-right { display: flex; justify-content: flex-end; }
+.toolbar-right {
+  display: flex;
+  justify-content: flex-end;
+}
 
 .card-empty {
   text-align: center;
   opacity: 0.4;
   padding: 16px 0;
 }
+
 .outbound-card {
   border: 1px solid rgba(128, 128, 128, 0.2);
   border-radius: 8px;
@@ -415,24 +401,28 @@ const rows = computed(() => {
   flex-direction: column;
   gap: 8px;
 }
+
 .card-head {
   display: flex;
   align-items: flex-start;
   justify-content: space-between;
   gap: 8px;
 }
+
 .card-identity {
   display: flex;
   flex-wrap: wrap;
   align-items: center;
   gap: 6px;
 }
+
 .card-num {
   font-weight: 500;
   opacity: 0.7;
   min-width: 18px;
   text-align: right;
 }
+
 .tag-name {
   font-weight: 500;
   max-width: 200px;
@@ -441,6 +431,7 @@ const rows = computed(() => {
   white-space: nowrap;
   display: inline-block;
 }
+
 .protocol-line {
   display: inline-flex;
   flex-wrap: wrap;
@@ -452,12 +443,14 @@ const rows = computed(() => {
   flex-wrap: wrap;
   gap: 4px;
 }
+
 .address-pill {
   font-size: 11px;
   padding: 2px 6px;
   border-radius: 4px;
   background: rgba(0, 0, 0, 0.05);
 }
+
 :global(body.dark) .address-pill {
   background: rgba(255, 255, 255, 0.06);
 }
@@ -467,6 +460,7 @@ const rows = computed(() => {
   align-items: center;
   gap: 6px;
 }
+
 .row-index {
   font-weight: 500;
   opacity: 0.7;
@@ -487,6 +481,7 @@ const rows = computed(() => {
   gap: 12px;
   flex-wrap: wrap;
 }
+
 .card-test {
   margin-left: auto;
   display: inline-flex;
@@ -494,9 +489,20 @@ const rows = computed(() => {
   gap: 8px;
 }
 
-.traffic-up { color: #008771; font-size: 12px; }
-.traffic-down { color: #3c89e8; font-size: 12px; }
-.traffic-sep { display: inline-block; width: 4px; }
+.traffic-up {
+  color: #008771;
+  font-size: 12px;
+}
+
+.traffic-down {
+  color: #3c89e8;
+  font-size: 12px;
+}
+
+.traffic-sep {
+  display: inline-block;
+  width: 4px;
+}
 
 .pill-ok,
 .pill-fail {
@@ -507,9 +513,22 @@ const rows = computed(() => {
   border-radius: 12px;
   font-size: 12px;
 }
-.pill-ok { color: #008771; background: rgba(0, 135, 113, 0.12); }
-.pill-fail { color: #e04141; background: rgba(224, 65, 65, 0.12); }
 
-.empty { opacity: 0.4; }
-.danger { color: #ff4d4f; }
+.pill-ok {
+  color: #008771;
+  background: rgba(0, 135, 113, 0.12);
+}
+
+.pill-fail {
+  color: #e04141;
+  background: rgba(224, 65, 65, 0.12);
+}
+
+.empty {
+  opacity: 0.4;
+}
+
+.danger {
+  color: #ff4d4f;
+}
 </style>

+ 37 - 28
frontend/src/pages/xray/RoutingTab.vue

@@ -169,19 +169,14 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
 <template>
   <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
     <a-button type="primary" @click="openAdd">
-      <template #icon><PlusOutlined /></template>
+      <template #icon>
+        <PlusOutlined />
+      </template>
       {{ t('pages.xray.Routings') }}
     </a-button>
 
-    <a-table
-      :columns="columns"
-      :data-source="rows"
-      :row-key="(r) => r.key"
-      :pagination="false"
-      :scroll="isMobile ? {} : { x: 1000 }"
-      size="small"
-      class="routing-table"
-    >
+    <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
+      :scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
       <template #bodyCell="{ column, record, index }">
         <!-- ============== # / actions ============== -->
         <template v-if="column.key === 'action'">
@@ -218,21 +213,24 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
               <span class="criterion-row">
                 <span class="criterion-label">IP</span>
                 <span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
-                <span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1 }}</span>
+                <span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
+                  }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
               <span class="criterion-row">
                 <span class="criterion-label">Port</span>
                 <span class="criterion-value">{{ csv(record.sourcePort)[0] }}</span>
-                <span v-if="csv(record.sourcePort).length > 1" class="criterion-more">+{{ csv(record.sourcePort).length - 1 }}</span>
+                <span v-if="csv(record.sourcePort).length > 1" class="criterion-more">+{{ csv(record.sourcePort).length
+                  - 1 }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.vlessRoute" :title="`VLESS route: ${record.vlessRoute}`">
               <span class="criterion-row">
                 <span class="criterion-label">VLESS</span>
                 <span class="criterion-value">{{ csv(record.vlessRoute)[0] }}</span>
-                <span v-if="csv(record.vlessRoute).length > 1" class="criterion-more">+{{ csv(record.vlessRoute).length - 1 }}</span>
+                <span v-if="csv(record.vlessRoute).length > 1" class="criterion-more">+{{ csv(record.vlessRoute).length
+                  - 1 }}</span>
               </span>
             </a-tooltip>
             <span v-if="!record.sourceIP && !record.sourcePort && !record.vlessRoute" class="criterion-empty">—</span>
@@ -246,14 +244,16 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
               <span class="criterion-row">
                 <span class="criterion-label">L4</span>
                 <span class="criterion-value">{{ csv(record.network)[0] }}</span>
-                <span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1 }}</span>
+                <span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
+                  }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
               <span class="criterion-row">
                 <span class="criterion-label">Protocol</span>
                 <span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
-                <span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1 }}</span>
+                <span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
+                  }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
@@ -280,14 +280,16 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
               <span class="criterion-row">
                 <span class="criterion-label">Domain</span>
                 <span class="criterion-value">{{ csv(record.domain)[0] }}</span>
-                <span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1 }}</span>
+                <span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
+                  }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
               <span class="criterion-row">
                 <span class="criterion-label">Port</span>
                 <span class="criterion-value">{{ csv(record.port)[0] }}</span>
-                <span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1 }}</span>
+                <span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
+                  }}</span>
               </span>
             </a-tooltip>
             <span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
@@ -301,14 +303,16 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
               <span class="criterion-row">
                 <span class="criterion-label">Tag</span>
                 <span class="criterion-value">{{ csv(record.inboundTag)[0] }}</span>
-                <span v-if="csv(record.inboundTag).length > 1" class="criterion-more">+{{ csv(record.inboundTag).length - 1 }}</span>
+                <span v-if="csv(record.inboundTag).length > 1" class="criterion-more">+{{ csv(record.inboundTag).length
+                  - 1 }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.user" :title="`User: ${record.user}`">
               <span class="criterion-row">
                 <span class="criterion-label">User</span>
                 <span class="criterion-value">{{ csv(record.user)[0] }}</span>
-                <span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1 }}</span>
+                <span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
+                  }}</span>
               </span>
             </a-tooltip>
             <span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
@@ -332,14 +336,8 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
       </template>
     </a-table>
 
-    <RuleFormModal
-      v-model:open="ruleModalOpen"
-      :rule="editingRule"
-      :inbound-tags="inboundTagOptions"
-      :outbound-tags="outboundTagOptions"
-      :balancer-tags="balancerTagOptions"
-      @confirm="onRuleConfirm"
-    />
+    <RuleFormModal v-model:open="ruleModalOpen" :rule="editingRule" :inbound-tags="inboundTagOptions"
+      :outbound-tags="outboundTagOptions" :balancer-tags="balancerTagOptions" @confirm="onRuleConfirm" />
   </a-space>
 </template>
 
@@ -349,6 +347,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
   align-items: center;
   gap: 6px;
 }
+
 .row-index {
   font-weight: 500;
   opacity: 0.7;
@@ -362,30 +361,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
   gap: 2px;
   font-size: 12px;
 }
+
 .criterion-row {
   display: inline-flex;
   align-items: baseline;
   gap: 4px;
   white-space: nowrap;
 }
+
 .criterion-label {
   font-size: 10px;
   text-transform: uppercase;
   opacity: 0.55;
   letter-spacing: 0.04em;
 }
+
 .criterion-value {
   font-weight: 500;
 }
+
 .criterion-more {
   font-size: 11px;
   padding: 0 5px;
   border-radius: 8px;
   background: rgba(0, 0, 0, 0.06);
 }
+
 :global(body.dark) .criterion-more {
   background: rgba(255, 255, 255, 0.1);
 }
+
 .criterion-empty {
   opacity: 0.4;
 }
@@ -395,15 +400,19 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
   flex-direction: column;
   gap: 2px;
 }
+
 .target-row {
   display: flex;
   align-items: center;
   gap: 4px;
 }
+
 .target-icon {
   font-size: 12px;
   opacity: 0.6;
 }
 
-.danger { color: #ff4d4f; }
+.danger {
+  color: #ff4d4f;
+}
 </style>

+ 35 - 23
frontend/src/pages/xray/RuleFormModal.vue

@@ -137,21 +137,14 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
 </script>
 
 <template>
-  <a-modal
-    :open="open"
-    :title="title"
-    :ok-text="okText"
-    :cancel-text="t('close')"
-    :mask-closable="false"
-    width="640px"
-    @ok="onOk"
-    @cancel="close"
-  >
+  <a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :mask-closable="false" width="640px"
+    @ok="onOk" @cancel="close">
     <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
       <a-form-item>
         <template #label>
           <a-tooltip title="Comma-separated list">
-            Source IPs <QuestionCircleOutlined />
+            Source IPs
+            <QuestionCircleOutlined />
           </a-tooltip>
         </template>
         <a-input v-model:value="form.sourceIP" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
@@ -160,7 +153,8 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
       <a-form-item>
         <template #label>
           <a-tooltip title="Comma-separated list">
-            Source port <QuestionCircleOutlined />
+            Source port
+            <QuestionCircleOutlined />
           </a-tooltip>
         </template>
         <a-input v-model:value="form.sourcePort" placeholder="53,443,1000-2000" />
@@ -169,7 +163,8 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
       <a-form-item>
         <template #label>
           <a-tooltip title="Comma-separated list">
-            VLESS route <QuestionCircleOutlined />
+            VLESS route
+            <QuestionCircleOutlined />
           </a-tooltip>
         </template>
         <a-input v-model:value="form.vlessRoute" placeholder="53,443,1000-2000" />
@@ -189,7 +184,9 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
 
       <a-form-item label="Attributes">
         <a-button size="small" @click="form.attrs.push(['', ''])">
-          <template #icon><PlusOutlined /></template>
+          <template #icon>
+            <PlusOutlined />
+          </template>
         </a-button>
       </a-form-item>
       <a-form-item :wrapper-col="{ span: 24 }">
@@ -199,35 +196,45 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
           </a-input>
           <a-input :style="{ width: '45%' }" v-model:value="attr[1]" placeholder="Value" />
           <a-button @click="form.attrs.splice(idx, 1)">
-            <template #icon><MinusOutlined /></template>
+            <template #icon>
+              <MinusOutlined />
+            </template>
           </a-button>
         </a-input-group>
       </a-form-item>
 
       <a-form-item>
         <template #label>
-          <a-tooltip title="Comma-separated list">IP <QuestionCircleOutlined /></a-tooltip>
+          <a-tooltip title="Comma-separated list">IP
+            <QuestionCircleOutlined />
+          </a-tooltip>
         </template>
         <a-input v-model:value="form.ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
       </a-form-item>
 
       <a-form-item>
         <template #label>
-          <a-tooltip title="Comma-separated list">Domain <QuestionCircleOutlined /></a-tooltip>
+          <a-tooltip title="Comma-separated list">Domain
+            <QuestionCircleOutlined />
+          </a-tooltip>
         </template>
         <a-input v-model:value="form.domain" placeholder="google.com, geosite:cn" />
       </a-form-item>
 
       <a-form-item>
         <template #label>
-          <a-tooltip title="Comma-separated list">User <QuestionCircleOutlined /></a-tooltip>
+          <a-tooltip title="Comma-separated list">User
+            <QuestionCircleOutlined />
+          </a-tooltip>
         </template>
         <a-input v-model:value="form.user" placeholder="email address" />
       </a-form-item>
 
       <a-form-item>
         <template #label>
-          <a-tooltip title="Comma-separated list">Port <QuestionCircleOutlined /></a-tooltip>
+          <a-tooltip title="Comma-separated list">Port
+            <QuestionCircleOutlined />
+          </a-tooltip>
         </template>
         <a-input v-model:value="form.port" placeholder="53,443,1000-2000" />
       </a-form-item>
@@ -240,18 +247,21 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
 
       <a-form-item label="Outbound tag">
         <a-select v-model:value="form.outboundTag">
-          <a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)' }}</a-select-option>
+          <a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
+            }}</a-select-option>
         </a-select>
       </a-form-item>
 
       <a-form-item>
         <template #label>
           <a-tooltip title="Routes traffic through one of the configured load balancers">
-            Balancer tag <QuestionCircleOutlined />
+            Balancer tag
+            <QuestionCircleOutlined />
           </a-tooltip>
         </template>
         <a-select v-model:value="form.balancerTag">
-          <a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)' }}</a-select-option>
+          <a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
+            }}</a-select-option>
         </a-select>
       </a-form-item>
     </a-form>
@@ -259,5 +269,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
 </template>
 
 <style scoped>
-.mb-8 { margin-bottom: 8px; }
+.mb-8 {
+  margin-bottom: 8px;
+}
 </style>

+ 38 - 24
frontend/src/pages/xray/WarpModal.vue

@@ -182,14 +182,7 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
 </script>
 
 <template>
-  <a-modal
-    :open="open"
-    title="Cloudflare WARP"
-    :footer="null"
-    :closable="true"
-    :mask-closable="true"
-    @cancel="close"
-  >
+  <a-modal :open="open" title="Cloudflare WARP" :footer="null" :closable="true" :mask-closable="true" @cancel="close">
     <!-- WARP / NordVPN provisioning forms keep technical wire labels in
          English on purpose: they map directly to API field names users
          look up in vendor docs. Only the primary action buttons +
@@ -197,7 +190,9 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
     <!-- Not registered yet → single Create CTA -->
     <template v-if="!hasWarp">
       <a-button type="primary" :loading="loading" @click="register">
-        <template #icon><ApiOutlined /></template>
+        <template #icon>
+          <ApiOutlined />
+        </template>
         Create WARP account
       </a-button>
     </template>
@@ -226,7 +221,9 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
       </table>
 
       <a-button :loading="loading" type="primary" danger class="mt-8" @click="delConfig">
-        <template #icon><DeleteOutlined /></template>
+        <template #icon>
+          <DeleteOutlined />
+        </template>
         Delete account
       </a-button>
 
@@ -237,13 +234,8 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
           <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
             <a-form-item label="Key">
               <a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" />
-              <a-button
-                type="primary"
-                class="mt-8"
-                :disabled="warpPlus.length < 26"
-                :loading="loading"
-                @click="updateLicense"
-              >Update</a-button>
+              <a-button type="primary" class="mt-8" :disabled="warpPlus.length < 26" :loading="loading"
+                @click="updateLicense">Update</a-button>
             </a-form-item>
           </a-form>
         </a-collapse-panel>
@@ -251,7 +243,9 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
 
       <a-divider class="zero-margin">Account info</a-divider>
       <a-button class="my-8" :loading="loading" type="primary" @click="getConfig">
-        <template #icon><SyncOutlined /></template>
+        <template #icon>
+          <SyncOutlined />
+        </template>
         Refresh
       </a-button>
 
@@ -305,7 +299,9 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
         <template v-else>
           <a-tag color="orange">Disabled</a-tag>
           <a-button type="primary" :loading="loading" class="ml-8" @click="addOutbound">
-            <template #icon><PlusOutlined /></template>
+            <template #icon>
+              <PlusOutlined />
+            </template>
             Add outbound
           </a-button>
         </template>
@@ -320,28 +316,46 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
   width: 100%;
   border-collapse: collapse;
 }
+
 .warp-data-table td {
   padding: 4px 8px;
   word-break: break-all;
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
   font-size: 12px;
 }
+
 .warp-data-table td:first-child {
   font-family: inherit;
   font-weight: 500;
   white-space: nowrap;
   width: 130px;
 }
+
 .row-odd {
   background: rgba(0, 0, 0, 0.03);
 }
+
 :global(body.dark) .row-odd {
   background: rgba(255, 255, 255, 0.04);
 }
 
-.zero-margin { margin: 0; }
-.my-8 { margin: 8px 0; }
-.mt-8 { margin-top: 8px; }
-.my-10 { margin: 10px 0; }
-.ml-8 { margin-left: 8px; }
+.zero-margin {
+  margin: 0;
+}
+
+.my-8 {
+  margin: 8px 0;
+}
+
+.mt-8 {
+  margin-top: 8px;
+}
+
+.my-10 {
+  margin: 10px 0;
+}
+
+.ml-8 {
+  margin-left: 8px;
+}
 </style>

+ 42 - 83
frontend/src/pages/xray/XrayPage.vue

@@ -207,10 +207,7 @@ function confirmRestart() {
 
 <template>
   <a-config-provider :theme="antdThemeConfig">
-    <a-layout
-      class="xray-page"
-      :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
-    >
+    <a-layout class="xray-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
       <AppSidebar :base-path="basePath" :request-uri="requestUri" />
 
       <a-layout class="content-shell">
@@ -218,12 +215,7 @@ function confirmRestart() {
           <a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
             <div v-if="!fetched" class="loading-spacer" />
 
-            <a-result
-              v-else-if="fetchError"
-              status="error"
-              :title="t('somethingWentWrong')"
-              :sub-title="fetchError"
-            >
+            <a-result v-else-if="fetchError" status="error" :title="t('somethingWentWrong')" :sub-title="fetchError">
               <template #extra>
                 <a-button type="primary" @click="fetchAll">{{ t('check') }}</a-button>
               </template>
@@ -254,11 +246,7 @@ function confirmRestart() {
                       </a-col>
                       <a-col :xs="24" :sm="10" class="header-info">
                         <a-back-top :target="scrollTarget" :visibility-height="200" />
-                        <a-alert
-                          type="warning"
-                          show-icon
-                          :message="t('pages.settings.infoDesc')"
-                        />
+                        <a-alert type="warning" show-icon :message="t('pages.settings.infoDesc')" />
                       </a-col>
                     </a-row>
                   </a-card>
@@ -271,56 +259,35 @@ function confirmRestart() {
                       <template #tab>
                         <SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
                       </template>
-                      <BasicsTab
-                        :template-settings="templateSettings"
-                        :outbound-test-url="outboundTestUrl"
-                        :warp-exist="warpExist"
-                        :nord-exist="nordExist"
-                        @update:outbound-test-url="(v) => (outboundTestUrl = v)"
-                        @show-warp="showWarp"
-                        @show-nord="showNord"
-                        @reset-default="resetToDefault"
-                      />
+                      <BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
+                        :warp-exist="warpExist" :nord-exist="nordExist"
+                        @update:outbound-test-url="(v) => (outboundTestUrl = v)" @show-warp="showWarp"
+                        @show-nord="showNord" @reset-default="resetToDefault" />
                     </a-tab-pane>
 
                     <a-tab-pane key="tpl-routing" class="tab-pane">
                       <template #tab>
                         <SwapOutlined /> <span>{{ t('pages.xray.Routings') }}</span>
                       </template>
-                      <RoutingTab
-                        :template-settings="templateSettings"
-                        :inbound-tags="inboundTags"
-                        :client-reverse-tags="clientReverseTags"
-                        :is-mobile="isMobile"
-                      />
+                      <RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
+                        :client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
                     </a-tab-pane>
 
                     <a-tab-pane key="tpl-outbound" class="tab-pane">
                       <template #tab>
                         <UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
                       </template>
-                      <OutboundsTab
-                        :template-settings="templateSettings"
-                        :outbounds-traffic="outboundsTraffic"
-                        :outbound-test-states="outboundTestStates"
-                        :inbound-tags="inboundTags"
-                        :is-mobile="isMobile"
-                        @reset-traffic="resetOutboundsTraffic"
-                        @test="onTestOutbound"
-                        @delete="onDeleteOutbound"
-                        @show-warp="showWarp"
-                        @show-nord="showNord"
-                      />
+                      <OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
+                        :outbound-test-states="outboundTestStates" :inbound-tags="inboundTags" :is-mobile="isMobile"
+                        @reset-traffic="resetOutboundsTraffic" @test="onTestOutbound" @delete="onDeleteOutbound"
+                        @show-warp="showWarp" @show-nord="showNord" />
                     </a-tab-pane>
 
                     <a-tab-pane key="tpl-balancer" class="tab-pane">
                       <template #tab>
                         <ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
                       </template>
-                      <BalancersTab
-                        :template-settings="templateSettings"
-                        :client-reverse-tags="clientReverseTags"
-                      />
+                      <BalancersTab :template-settings="templateSettings" :client-reverse-tags="clientReverseTags" />
                     </a-tab-pane>
 
                     <a-tab-pane key="tpl-dns" class="tab-pane">
@@ -334,27 +301,16 @@ function confirmRestart() {
                       <template #tab>
                         <CodeOutlined /> <span>{{ t('pages.xray.advancedTemplate') }}</span>
                       </template>
-                      <a-list-item-meta
-                        :title="t('pages.xray.Template')"
-                        :description="t('pages.xray.TemplateDesc')"
-                      />
-                      <a-radio-group
-                        v-model:value="advSettings"
-                        button-style="solid"
-                        :size="isMobile ? 'small' : 'middle'"
-                        :style="{ margin: '12px 0' }"
-                      >
+                      <a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
+                      <a-radio-group v-model:value="advSettings" button-style="solid"
+                        :size="isMobile ? 'small' : 'middle'" :style="{ margin: '12px 0' }">
                         <a-radio-button value="xraySetting">{{ t('pages.xray.completeTemplate') }}</a-radio-button>
                         <a-radio-button value="inboundSettings">{{ t('pages.xray.Inbounds') }}</a-radio-button>
                         <a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
                         <a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
                       </a-radio-group>
-                      <a-textarea
-                        v-model:value="advancedText"
-                        :auto-size="{ minRows: 18, maxRows: 40 }"
-                        spellcheck="false"
-                        class="json-editor"
-                      />
+                      <a-textarea v-model:value="advancedText" :auto-size="{ minRows: 18, maxRows: 40 }"
+                        spellcheck="false" class="json-editor" />
                     </a-tab-pane>
                   </a-tabs>
                 </a-col>
@@ -364,21 +320,11 @@ function confirmRestart() {
         </a-layout-content>
       </a-layout>
 
-      <WarpModal
-        v-model:open="warpOpen"
-        :template-settings="templateSettings"
-        @add-outbound="onAddOutbound"
-        @reset-outbound="onResetOutbound"
-        @remove-outbound="onRemoveOutboundByTag"
-      />
-      <NordModal
-        v-model:open="nordOpen"
-        :template-settings="templateSettings"
-        @add-outbound="onAddOutbound"
-        @reset-outbound="onResetOutbound"
-        @remove-outbound="onRemoveOutboundByIndex"
-        @remove-routing-rules="onRemoveRoutingRules"
-      />
+      <WarpModal v-model:open="warpOpen" :template-settings="templateSettings" @add-outbound="onAddOutbound"
+        @reset-outbound="onResetOutbound" @remove-outbound="onRemoveOutboundByTag" />
+      <NordModal v-model:open="nordOpen" :template-settings="templateSettings" @add-outbound="onAddOutbound"
+        @reset-outbound="onResetOutbound" @remove-outbound="onRemoveOutboundByIndex"
+        @remove-routing-rules="onRemoveRoutingRules" />
     </a-layout>
   </a-config-provider>
 </template>
@@ -407,23 +353,36 @@ function confirmRestart() {
   background: transparent;
 }
 
-.content-shell { background: transparent; }
-.content-area { padding: 24px; }
+.content-shell {
+  background: transparent;
+}
 
-.loading-spacer { min-height: calc(100vh - 120px); }
+.content-area {
+  padding: 24px;
+}
+
+.loading-spacer {
+  min-height: calc(100vh - 120px);
+}
 
 .header-row {
   display: flex;
   flex-wrap: wrap;
   align-items: center;
 }
-.header-actions { padding: 4px; }
+
+.header-actions {
+  padding: 4px;
+}
+
 .header-info {
   display: flex;
   justify-content: flex-end;
 }
 
-.tab-pane { padding-top: 20px; }
+.tab-pane {
+  padding-top: 20px;
+}
 
 .restart-icon {
   font-size: 16px;

+ 16 - 1
web/translation/ar-EG.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "استخدام ملف hosts من نظام مثبت",
         "usePreset": "استخدام النموذج",
         "dnsPresetTitle": "قوالب DNS",
-        "dnsPresetFamily": "العائلي"
+        "dnsPresetFamily": "العائلي",
+        "serveStale": "تقديم النتائج المنتهية",
+        "serveStaleDesc": "إرجاع نتائج الكاش المنتهية الصلاحية أثناء التحديث في الخلفية",
+        "serveExpiredTTL": "مدة صلاحية النتائج المنتهية",
+        "serveExpiredTTLDesc": "مدة صلاحية إدخالات الكاش المنتهية بالثواني؛ 0 = لا تنتهي أبدًا",
+        "timeoutMs": "المهلة (مللي ثانية)",
+        "skipFallback": "تخطي الاحتياطي",
+        "finalQuery": "الاستعلام النهائي",
+        "hosts": "Hosts",
+        "hostsAdd": "إضافة Host",
+        "hostsEmpty": "لم يتم تعريف أي Host",
+        "hostsDomain": "النطاق (مثل domain:example.com)",
+        "hostsValues": "عنوان IP أو نطاق — اكتب واضغط Enter",
+        "clearAll": "حذف الكل",
+        "clearAllTitle": "حذف جميع خوادم DNS؟",
+        "clearAllConfirm": "سيؤدي هذا إلى إزالة جميع خوادم DNS من القائمة. لا يمكن التراجع عن هذا الإجراء."
       },
       "fakedns": {
         "add": "أضف Fake DNS",

+ 16 - 1
web/translation/en-US.json

@@ -752,9 +752,24 @@
         "unexpectIPs": "Unexpect IPs",
         "useSystemHosts": "Use System Hosts",
         "useSystemHostsDesc": "Use the hosts file from an installed system",
+        "serveStale": "Serve Stale",
+        "serveStaleDesc": "Return expired cached results while refreshing in the background",
+        "serveExpiredTTL": "Serve Expired TTL",
+        "serveExpiredTTLDesc": "Validity (seconds) of stale cache entries; 0 = never expire",
+        "timeoutMs": "Timeout (ms)",
+        "skipFallback": "Skip Fallback",
+        "finalQuery": "Final Query",
+        "hosts": "Hosts",
+        "hostsAdd": "Add Host",
+        "hostsEmpty": "No host overrides defined",
+        "hostsDomain": "Domain (e.g. domain:example.com)",
+        "hostsValues": "IP or domain — type and press Enter",
         "usePreset": "Use Preset",
         "dnsPresetTitle": "DNS Presets",
-        "dnsPresetFamily": "Family"
+        "dnsPresetFamily": "Family",
+        "clearAll": "Delete All",
+        "clearAllTitle": "Delete all DNS servers?",
+        "clearAllConfirm": "This removes every DNS server from the list. This cannot be undone."
       },
       "fakedns": {
         "add": "Add Fake DNS",

+ 16 - 1
web/translation/es-ES.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "Usar el archivo hosts de un sistema instalado",
         "usePreset": "Usar plantilla",
         "dnsPresetTitle": "Plantillas DNS",
-        "dnsPresetFamily": "Familiar"
+        "dnsPresetFamily": "Familiar",
+        "serveStale": "Servir caducados",
+        "serveStaleDesc": "Devolver resultados caducados de la caché mientras se actualiza en segundo plano",
+        "serveExpiredTTL": "TTL de caducados",
+        "serveExpiredTTLDesc": "Validez (segundos) de las entradas caducadas en la caché; 0 = nunca caduca",
+        "timeoutMs": "Tiempo de espera (ms)",
+        "skipFallback": "Omitir respaldo",
+        "finalQuery": "Consulta final",
+        "hosts": "Hosts",
+        "hostsAdd": "Agregar Host",
+        "hostsEmpty": "No hay Hosts definidos",
+        "hostsDomain": "Dominio (ej. domain:example.com)",
+        "hostsValues": "IP o dominio — escribe y presiona Enter",
+        "clearAll": "Eliminar todos",
+        "clearAllTitle": "¿Eliminar todos los servidores DNS?",
+        "clearAllConfirm": "Esto eliminará todos los servidores DNS de la lista. No se puede deshacer."
       },
       "fakedns": {
         "add": "Agregar DNS Falso",

+ 16 - 1
web/translation/fa-IR.json

@@ -752,9 +752,24 @@
         "unexpectIPs": "آی‌پی‌های غیرمنتظره",
         "useSystemHosts": "استفاده از Hosts سیستم",
         "useSystemHostsDesc": "استفاده از فایل hosts یک سیستم نصب‌شده",
+        "serveStale": "ارائه نتایج منقضی",
+        "serveStaleDesc": "بازگرداندن نتایج منقضی کش هنگام بروزرسانی در پس‌زمینه",
+        "serveExpiredTTL": "TTL نتایج منقضی",
+        "serveExpiredTTLDesc": "مدت اعتبار نتایج منقضی به ثانیه؛ ۰ یعنی هرگز منقضی نمی‌شود",
+        "timeoutMs": "زمان انتظار (میلی‌ثانیه)",
+        "skipFallback": "رد کردن Fallback",
+        "finalQuery": "پرس‌وجوی نهایی",
+        "hosts": "Hosts",
+        "hostsAdd": "افزودن Host",
+        "hostsEmpty": "هیچ Host تعریف نشده",
+        "hostsDomain": "دامنه (مثلاً domain:example.com)",
+        "hostsValues": "آی‌پی یا دامنه — تایپ کنید و Enter بزنید",
         "usePreset": "استفاده از پیش‌تنظیم",
         "dnsPresetTitle": "پیش‌تنظیم‌های DNS",
-        "dnsPresetFamily": "خانوادگی"
+        "dnsPresetFamily": "خانوادگی",
+        "clearAll": "حذف همه",
+        "clearAllTitle": "حذف همه سرورهای DNS؟",
+        "clearAllConfirm": "این کار همه سرورهای DNS را از لیست حذف می‌کند و قابل بازگشت نیست."
       },
       "fakedns": {
         "add": "افزودن دی‌ان‌اس جعلی",

+ 16 - 1
web/translation/id-ID.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "Gunakan file hosts dari sistem yang terinstal",
         "usePreset": "Gunakan templat",
         "dnsPresetTitle": "Templat DNS",
-        "dnsPresetFamily": "Keluarga"
+        "dnsPresetFamily": "Keluarga",
+        "serveStale": "Sajikan Kedaluwarsa",
+        "serveStaleDesc": "Mengembalikan hasil cache yang kedaluwarsa saat memperbarui di latar belakang",
+        "serveExpiredTTL": "TTL Kedaluwarsa",
+        "serveExpiredTTLDesc": "Masa berlaku (detik) entri cache kedaluwarsa; 0 = tidak pernah kedaluwarsa",
+        "timeoutMs": "Batas waktu (ms)",
+        "skipFallback": "Lewati Fallback",
+        "finalQuery": "Kueri Akhir",
+        "hosts": "Hosts",
+        "hostsAdd": "Tambah Host",
+        "hostsEmpty": "Tidak ada Host yang ditentukan",
+        "hostsDomain": "Domain (mis. domain:example.com)",
+        "hostsValues": "IP atau domain — ketik dan tekan Enter",
+        "clearAll": "Hapus Semua",
+        "clearAllTitle": "Hapus semua server DNS?",
+        "clearAllConfirm": "Ini akan menghapus semua server DNS dari daftar. Tidak dapat dibatalkan."
       },
       "fakedns": {
         "add": "Tambahkan DNS Palsu",

+ 16 - 1
web/translation/ja-JP.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "インストール済みシステムのhostsファイルを使用する",
         "usePreset": "テンプレートを使用",
         "dnsPresetTitle": "DNSテンプレート",
-        "dnsPresetFamily": "ファミリー"
+        "dnsPresetFamily": "ファミリー",
+        "serveStale": "期限切れキャッシュを使用",
+        "serveStaleDesc": "バックグラウンドで更新中に期限切れキャッシュ結果を返す",
+        "serveExpiredTTL": "期限切れTTL",
+        "serveExpiredTTLDesc": "期限切れキャッシュエントリの有効期間(秒)。0 = 無期限",
+        "timeoutMs": "タイムアウト (ms)",
+        "skipFallback": "フォールバックをスキップ",
+        "finalQuery": "最終クエリ",
+        "hosts": "Hosts",
+        "hostsAdd": "Host を追加",
+        "hostsEmpty": "Host が定義されていません",
+        "hostsDomain": "ドメイン (例: domain:example.com)",
+        "hostsValues": "IP またはドメイン — 入力して Enter",
+        "clearAll": "すべて削除",
+        "clearAllTitle": "すべての DNS サーバを削除しますか?",
+        "clearAllConfirm": "リストからすべての DNS サーバが削除されます。この操作は元に戻せません。"
       },
       "fakedns": {
         "add": "フェイクDNS追加",

+ 16 - 1
web/translation/pt-BR.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "Usar o arquivo hosts de um sistema instalado",
         "usePreset": "Usar modelo",
         "dnsPresetTitle": "Modelos DNS",
-        "dnsPresetFamily": "Familiar"
+        "dnsPresetFamily": "Familiar",
+        "serveStale": "Servir Expirados",
+        "serveStaleDesc": "Retornar resultados expirados do cache enquanto atualiza em segundo plano",
+        "serveExpiredTTL": "TTL de Expirados",
+        "serveExpiredTTLDesc": "Validade (segundos) das entradas expiradas no cache; 0 = nunca expira",
+        "timeoutMs": "Tempo limite (ms)",
+        "skipFallback": "Ignorar Fallback",
+        "finalQuery": "Consulta Final",
+        "hosts": "Hosts",
+        "hostsAdd": "Adicionar Host",
+        "hostsEmpty": "Nenhum Host definido",
+        "hostsDomain": "Domínio (ex. domain:example.com)",
+        "hostsValues": "IP ou domínio — digite e pressione Enter",
+        "clearAll": "Remover Todos",
+        "clearAllTitle": "Remover todos os servidores DNS?",
+        "clearAllConfirm": "Isso remove todos os servidores DNS da lista. Não pode ser desfeito."
       },
       "fakedns": {
         "add": "Adicionar Fake DNS",

+ 16 - 1
web/translation/ru-RU.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "Использовать файл hosts из установленной системы",
         "usePreset": "Использовать шаблон",
         "dnsPresetTitle": "Шаблоны DNS",
-        "dnsPresetFamily": "Семейный"
+        "dnsPresetFamily": "Семейный",
+        "serveStale": "Использовать устаревшие",
+        "serveStaleDesc": "Возвращать устаревшие результаты из кэша во время обновления в фоне",
+        "serveExpiredTTL": "TTL устаревших",
+        "serveExpiredTTLDesc": "Срок действия (секунды) устаревших записей кэша; 0 = бессрочно",
+        "timeoutMs": "Тайм-аут (мс)",
+        "skipFallback": "Пропустить Fallback",
+        "finalQuery": "Финальный запрос",
+        "hosts": "Hosts",
+        "hostsAdd": "Добавить Host",
+        "hostsEmpty": "Host не определены",
+        "hostsDomain": "Домен (напр. domain:example.com)",
+        "hostsValues": "IP или домен — введите и нажмите Enter",
+        "clearAll": "Удалить все",
+        "clearAllTitle": "Удалить все DNS-серверы?",
+        "clearAllConfirm": "Все DNS-серверы будут удалены из списка. Это действие нельзя отменить."
       },
       "fakedns": {
         "add": "Создать Fake DNS",

+ 16 - 1
web/translation/tr-TR.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "Yüklü bir sistemden hosts dosyasını kullan",
         "usePreset": "Şablon kullan",
         "dnsPresetTitle": "DNS Şablonları",
-        "dnsPresetFamily": "Aile"
+        "dnsPresetFamily": "Aile",
+        "serveStale": "Süresi Dolmuş Sonuçları Sun",
+        "serveStaleDesc": "Arka planda yenilenirken süresi dolmuş önbellek sonuçlarını döndür",
+        "serveExpiredTTL": "Süresi Dolmuş TTL",
+        "serveExpiredTTLDesc": "Süresi dolmuş önbellek girdilerinin geçerlilik süresi (saniye); 0 = asla",
+        "timeoutMs": "Zaman aşımı (ms)",
+        "skipFallback": "Yedekleri Atla",
+        "finalQuery": "Son Sorgu",
+        "hosts": "Hosts",
+        "hostsAdd": "Host Ekle",
+        "hostsEmpty": "Tanımlı Host yok",
+        "hostsDomain": "Alan adı (ör. domain:example.com)",
+        "hostsValues": "IP veya alan adı — yazıp Enter'a basın",
+        "clearAll": "Tümünü Sil",
+        "clearAllTitle": "Tüm DNS sunucularını sil?",
+        "clearAllConfirm": "Bu, tüm DNS sunucularını listeden kaldırır. Geri alınamaz."
       },
       "fakedns": {
         "add": "Sahte DNS Ekle",

+ 16 - 1
web/translation/uk-UA.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "Використовувати файл hosts з встановленої системи",
         "usePreset": "Використати шаблон",
         "dnsPresetTitle": "Шаблони DNS",
-        "dnsPresetFamily": "Сімейний"
+        "dnsPresetFamily": "Сімейний",
+        "serveStale": "Видавати застарілі",
+        "serveStaleDesc": "Повертати застарілі результати з кешу під час фонового оновлення",
+        "serveExpiredTTL": "TTL застарілих",
+        "serveExpiredTTLDesc": "Термін дії (секунди) застарілих записів кешу; 0 = ніколи",
+        "timeoutMs": "Тайм-аут (мс)",
+        "skipFallback": "Пропустити Fallback",
+        "finalQuery": "Фінальний запит",
+        "hosts": "Hosts",
+        "hostsAdd": "Додати Host",
+        "hostsEmpty": "Host не визначено",
+        "hostsDomain": "Домен (напр. domain:example.com)",
+        "hostsValues": "IP або домен — введіть і натисніть Enter",
+        "clearAll": "Видалити всі",
+        "clearAllTitle": "Видалити всі DNS-сервери?",
+        "clearAllConfirm": "Усі DNS-сервери буде видалено зі списку. Дію не можна скасувати."
       },
       "fakedns": {
         "add": "Додати підроблений DNS",

+ 16 - 1
web/translation/vi-VN.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "Sử dụng file hosts từ hệ thống đã cài đặt",
         "usePreset": "Dùng mẫu",
         "dnsPresetTitle": "Mẫu DNS",
-        "dnsPresetFamily": "Gia đình"
+        "dnsPresetFamily": "Gia đình",
+        "serveStale": "Phục vụ kết quả hết hạn",
+        "serveStaleDesc": "Trả về kết quả cache đã hết hạn trong khi làm mới ở chế độ nền",
+        "serveExpiredTTL": "TTL hết hạn",
+        "serveExpiredTTLDesc": "Thời gian hiệu lực (giây) của các mục cache hết hạn; 0 = không bao giờ hết hạn",
+        "timeoutMs": "Thời gian chờ (ms)",
+        "skipFallback": "Bỏ qua Fallback",
+        "finalQuery": "Truy vấn cuối",
+        "hosts": "Hosts",
+        "hostsAdd": "Thêm Host",
+        "hostsEmpty": "Chưa có Host nào",
+        "hostsDomain": "Tên miền (vd. domain:example.com)",
+        "hostsValues": "IP hoặc tên miền — nhập và nhấn Enter",
+        "clearAll": "Xóa tất cả",
+        "clearAllTitle": "Xóa tất cả máy chủ DNS?",
+        "clearAllConfirm": "Thao tác này sẽ xóa toàn bộ máy chủ DNS khỏi danh sách. Không thể hoàn tác."
       },
       "fakedns": {
         "add": "Thêm DNS giả",

+ 16 - 1
web/translation/zh-CN.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "使用已安装系统的hosts文件",
         "usePreset": "使用模板",
         "dnsPresetTitle": "DNS模板",
-        "dnsPresetFamily": "家庭"
+        "dnsPresetFamily": "家庭",
+        "serveStale": "提供过期结果",
+        "serveStaleDesc": "在后台刷新时返回过期的缓存结果",
+        "serveExpiredTTL": "过期TTL",
+        "serveExpiredTTLDesc": "过期缓存条目的有效期(秒);0 = 永不过期",
+        "timeoutMs": "超时 (毫秒)",
+        "skipFallback": "跳过回退",
+        "finalQuery": "最终查询",
+        "hosts": "Hosts",
+        "hostsAdd": "添加 Host",
+        "hostsEmpty": "未定义任何 Host",
+        "hostsDomain": "域名 (例如 domain:example.com)",
+        "hostsValues": "IP 或域名 — 输入后按 Enter",
+        "clearAll": "删除全部",
+        "clearAllTitle": "删除所有 DNS 服务器?",
+        "clearAllConfirm": "此操作将从列表中删除所有 DNS 服务器,且无法撤销。"
       },
       "fakedns": {
         "add": "添加假 DNS",

+ 16 - 1
web/translation/zh-TW.json

@@ -754,7 +754,22 @@
         "useSystemHostsDesc": "使用已安裝系統的hosts檔案",
         "usePreset": "使用範本",
         "dnsPresetTitle": "DNS範本",
-        "dnsPresetFamily": "家庭"
+        "dnsPresetFamily": "家庭",
+        "serveStale": "提供過期結果",
+        "serveStaleDesc": "在背景重新整理時傳回過期的快取結果",
+        "serveExpiredTTL": "過期TTL",
+        "serveExpiredTTLDesc": "過期快取項目的有效期(秒);0 = 永不過期",
+        "timeoutMs": "逾時 (毫秒)",
+        "skipFallback": "跳過回退",
+        "finalQuery": "最終查詢",
+        "hosts": "Hosts",
+        "hostsAdd": "新增 Host",
+        "hostsEmpty": "未定義任何 Host",
+        "hostsDomain": "網域 (例如 domain:example.com)",
+        "hostsValues": "IP 或網域 — 輸入後按 Enter",
+        "clearAll": "全部刪除",
+        "clearAllTitle": "刪除所有 DNS 伺服器?",
+        "clearAllConfirm": "此操作將從清單中刪除所有 DNS 伺服器,無法復原。"
       },
       "fakedns": {
         "add": "新增假 DNS",