6 次代碼提交 1284756f8a ... b79abc8bc9

作者 SHA1 備註 提交日期
  MHSanaei b79abc8bc9 refactor: remove legacy advancedJson state 15 小時之前
  MHSanaei 05b68c3b13 fix: remove Auth password 16 小時之前
  Abdalrahman f3c7660f84 fix: correct Hysteria2 Obfs password label to Auth password (#4388) 16 小時之前
  MHSanaei 9b0fd047cb fix: guard certificate and key against undefined before join 18 小時之前
  MHSanaei e4218a1029 feat: click QR to copy/save image instead of link text 18 小時之前
  Fedor Batonogov 7065d41be6 docs(readme): add Community Tools section (#4114) 19 小時之前

+ 6 - 0
README.ar_EG.md

@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
 - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
 
+## أدوات المجتمع
+
+أدوات وتكاملات بناها المجتمع حول 3x-ui.
+
+- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (الترخيص: **MIT**): _إدارة الاتصالات الواردة والعملاء وإعدادات اللوحة وتكوين Xray كرمز باستخدام Terraform / OpenTofu._
+
 ## دعم المشروع
 
 **إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:

+ 6 - 0
README.es_ES.md

@@ -39,6 +39,12 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
 - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
 - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
 
+## Herramientas de la Comunidad
+
+Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
+
+- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Licencia: **MIT**): _Gestiona inbounds, clientes, configuración del panel y configuración de Xray como código con Terraform / OpenTofu._
+
 ## Apoyar el Proyecto
 
 **Si este proyecto te es útil, puedes darle una**:star2:

+ 6 - 0
README.fa_IR.md

@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنه‌های ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
 - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray به‌روزرسانی شده خودکار بر اساس داده‌های دامنه‌ها و آدرس‌های مسدود شده در روسیه است._
 
+## ابزارهای جامعه
+
+ابزارها و یکپارچه‌سازی‌هایی که توسط جامعه پیرامون 3x-ui ساخته شده‌اند.
+
+- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (مجوز: **MIT**): _مدیریت اینباندها، کلاینت‌ها، تنظیمات پنل و پیکربندی Xray به‌صورت کد با Terraform / OpenTofu._
+
 ## پشتیبانی از پروژه
 
 **اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید

+ 6 - 0
README.md

@@ -39,6 +39,12 @@ For full documentation, please visit the [project Wiki](https://github.com/MHSan
 - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
 - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
 
+## Community Tools
+
+Tools and integrations built by the community around 3x-ui.
+
+- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
+
 ## Support project
 
 **If this project is helpful to you, you may wish to give it a**:star2:

+ 6 - 0
README.ru_RU.md

@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
 - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
 
+## Инструменты сообщества
+
+Инструменты и интеграции, созданные сообществом вокруг 3x-ui.
+
+- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Лицензия: **MIT**): _Управление входящими, клиентами, настройками панели и конфигурацией Xray через код с помощью Terraform / OpenTofu._
+
 ## Поддержка проекта
 
 **Если этот проект полезен для вас, вы можете поставить ему**:star2:

+ 6 - 0
README.zh_CN.md

@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 - [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则,内置伊朗域名,专注于安全性和广告拦截。_
 - [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
 
+## 社区工具
+
+社区围绕 3x-ui 构建的工具和集成。
+
+- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (许可证: **MIT**): _使用 Terraform / OpenTofu 通过代码管理入站、客户端、面板设置和 Xray 配置。_
+
 ## 支持项目
 
 **如果这个项目对您有帮助,您可以给它一个**:star2:

+ 2 - 2
frontend/src/models/inbound.js

@@ -827,8 +827,8 @@ TlsStreamSettings.Cert = class extends XrayCommonClass {
         } else {
             return new TlsStreamSettings.Cert(
                 false, '', '',
-                json.certificate.join('\n'),
-                json.key.join('\n'),
+                Array.isArray(json.certificate) ? json.certificate.join('\n') : (json.certificate ?? ''),
+                Array.isArray(json.key) ? json.key.join('\n') : (json.key ?? ''),
                 json.oneTimeLoading,
                 json.usage,
                 json.buildChain,

+ 327 - 39
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -70,8 +70,11 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const inbound = ref(null);
 const dbForm = ref(null);
 const saving = ref(false);
-const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
+const advancedStreamText = ref('');
+const advancedSniffingText = ref('');
+const advancedSettingsText = ref('');
 const activeTabKey = ref('basic');
+const advancedSectionKey = ref('all');
 // Cached default cert/key paths from /panel/setting/defaultSettings —
 // powers the "Set default cert" button on the TLS form.
 const defaultCert = ref('');
@@ -224,13 +227,13 @@ function freshDbForm() {
 function primeAdvancedJson() {
   if (!inbound.value) return;
   try {
-    advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
+    advancedStreamText.value = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
   } catch (_e) { /* keep prior text */ }
   try {
-    advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
+    advancedSniffingText.value = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
   } catch (_e) { /* keep prior text */ }
   try {
-    advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
+    advancedSettingsText.value = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
   } catch (_e) { /* keep prior text */ }
 }
 
@@ -244,6 +247,7 @@ watch(() => props.open, (next) => {
     primeAdvancedJson();
   }
   activeTabKey.value = 'basic';
+  advancedSectionKey.value = 'all';
   fetchDefaultCertSettings();
 });
 
@@ -253,18 +257,18 @@ function applyAdvancedJsonToBasic() {
   let parsedStream;
   let parsedSniffing;
   try {
-    parsedSettings = advancedJson.value.settings.trim()
-      ? JSON.parse(advancedJson.value.settings)
+    parsedSettings = advancedSettingsText.value.trim()
+      ? JSON.parse(advancedSettingsText.value)
       : inbound.value.settings?.toJson?.();
   } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
   try {
-    parsedStream = advancedJson.value.stream.trim()
-      ? JSON.parse(advancedJson.value.stream)
+    parsedStream = advancedStreamText.value.trim()
+      ? JSON.parse(advancedStreamText.value)
       : inbound.value.stream?.toJson?.();
   } catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
   try {
-    parsedSniffing = advancedJson.value.sniffing.trim()
-      ? JSON.parse(advancedJson.value.sniffing)
+    parsedSniffing = advancedSniffingText.value.trim()
+      ? JSON.parse(advancedSniffingText.value)
       : inbound.value.sniffing?.toJson?.();
   } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
 
@@ -324,6 +328,203 @@ function onNetworkChange(next) {
   }
 }
 
+function parseAdvancedSliceOrFallback(rawText, fallbackValue) {
+  if (!rawText?.trim()) return fallbackValue;
+  return JSON.parse(rawText);
+}
+
+function unwrapWrappedObject(parsed, key) {
+  if (
+    parsed
+    && typeof parsed === 'object'
+    && !Array.isArray(parsed)
+    && parsed[key] !== undefined
+  ) {
+    return parsed[key];
+  }
+  return parsed;
+}
+
+const advancedAllConfig = computed({
+  get: () => {
+    if (!inbound.value) return '';
+    try {
+      const settings = parseAdvancedSliceOrFallback(
+        advancedSettingsText.value,
+        inbound.value.settings?.toJson?.() || {},
+      );
+      const streamSettings = parseAdvancedSliceOrFallback(
+        advancedStreamText.value,
+        inbound.value.stream?.toJson?.() || {},
+      );
+      const sniffing = parseAdvancedSliceOrFallback(
+        advancedSniffingText.value,
+        inbound.value.sniffing?.toJson?.() || {},
+      );
+      return JSON.stringify({
+        listen: inbound.value.listen,
+        port: inbound.value.port,
+        protocol: inbound.value.protocol,
+        settings,
+        sniffing,
+        streamSettings,
+        tag: inbound.value.tag,
+      }, null, 2);
+    } catch (_e) {
+      return '';
+    }
+  },
+  set: (next) => {
+    let parsed;
+    try {
+      parsed = JSON.parse(next);
+    } catch (e) {
+      message.error(`All JSON invalid: ${e.message}`);
+      return;
+    }
+    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+      message.error('All JSON must be an inbound object.');
+      return;
+    }
+
+    try {
+      if (typeof parsed.listen === 'string') {
+        inbound.value.listen = parsed.listen;
+      }
+      if (parsed.port !== undefined) {
+        const parsedPort = Number(parsed.port);
+        if (!Number.isNaN(parsedPort) && Number.isFinite(parsedPort)) {
+          inbound.value.port = parsedPort;
+        }
+      }
+      if (typeof parsed.protocol === 'string' && PROTOCOLS.includes(parsed.protocol)) {
+        inbound.value.protocol = parsed.protocol;
+      }
+      if (typeof parsed.tag === 'string') {
+        inbound.value.tag = parsed.tag;
+      }
+
+      const existingSettings = parseAdvancedSliceOrFallback(
+        advancedSettingsText.value,
+        inbound.value?.settings?.toJson?.() || {},
+      );
+      const settings = parsed.settings ?? existingSettings;
+      const streamSettings = parsed.streamSettings ?? (inbound.value?.stream?.toJson?.() || {});
+      const sniffing = parsed.sniffing ?? (inbound.value?.sniffing?.toJson?.() || {});
+      advancedSettingsText.value = JSON.stringify(settings, null, 2);
+      advancedStreamText.value = JSON.stringify(streamSettings, null, 2);
+      advancedSniffingText.value = JSON.stringify(sniffing, null, 2);
+    } catch (e) {
+      message.error(`All JSON invalid: ${e.message}`);
+    }
+  },
+});
+
+const advancedSettingsConfig = computed({
+  get: () => {
+    if (!inbound.value) return '';
+    try {
+      const settings = parseAdvancedSliceOrFallback(
+        advancedSettingsText.value,
+        inbound.value.settings?.toJson?.() || {},
+      );
+      return JSON.stringify({
+        settings,
+      }, null, 2);
+    } catch (_e) {
+      return '';
+    }
+  },
+  set: (next) => {
+    let parsed;
+    try {
+      parsed = JSON.parse(next);
+    } catch (e) {
+      message.error(`Settings JSON invalid: ${e.message}`);
+      return;
+    }
+    const unwrapped = unwrapWrappedObject(parsed, 'settings');
+    if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
+      message.error('Settings JSON must be an object or { settings: { ... } }.');
+      return;
+    }
+
+    try {
+      advancedSettingsText.value = JSON.stringify(unwrapped, null, 2);
+    } catch (e) {
+      message.error(`Settings JSON invalid: ${e.message}`);
+    }
+  },
+});
+
+const advancedSniffingConfig = computed({
+  get: () => {
+    if (!inbound.value) return '';
+    try {
+      const sniffing = parseAdvancedSliceOrFallback(
+        advancedSniffingText.value,
+        inbound.value.sniffing?.toJson?.() || {},
+      );
+      return JSON.stringify({ sniffing }, null, 2);
+    } catch (_e) {
+      return '';
+    }
+  },
+  set: (next) => {
+    let parsed;
+    try {
+      parsed = JSON.parse(next);
+    } catch (e) {
+      message.error(`Sniffing JSON invalid: ${e.message}`);
+      return;
+    }
+    const unwrapped = unwrapWrappedObject(parsed, 'sniffing');
+    if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
+      message.error('Sniffing JSON must be an object or { sniffing: { ... } }.');
+      return;
+    }
+    try {
+      advancedSniffingText.value = JSON.stringify(unwrapped, null, 2);
+    } catch (e) {
+      message.error(`Sniffing JSON invalid: ${e.message}`);
+    }
+  },
+});
+
+const advancedStreamConfig = computed({
+  get: () => {
+    if (!inbound.value) return '';
+    try {
+      const streamSettings = parseAdvancedSliceOrFallback(
+        advancedStreamText.value,
+        inbound.value.stream?.toJson?.() || {},
+      );
+      return JSON.stringify({ streamSettings }, null, 2);
+    } catch (_e) {
+      return '';
+    }
+  },
+  set: (next) => {
+    let parsed;
+    try {
+      parsed = JSON.parse(next);
+    } catch (e) {
+      message.error(`Stream JSON invalid: ${e.message}`);
+      return;
+    }
+    const unwrapped = unwrapWrappedObject(parsed, 'streamSettings');
+    if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
+      message.error('Stream JSON must be an object or { streamSettings: { ... } }.');
+      return;
+    }
+    try {
+      advancedStreamText.value = JSON.stringify(unwrapped, null, 2);
+    } catch (e) {
+      message.error(`Stream JSON invalid: ${e.message}`);
+    }
+  },
+});
+
 // === Random helpers wired to the form's sync icons ==================
 function randomEmail(target) {
   if (target) target.email = RandomUtil.randomLowerAndNum(9);
@@ -525,16 +726,16 @@ async function submit() {
     let settings;
     try {
       streamSettings = canEnableStream.value
-        ? JSON.stringify(JSON.parse(advancedJson.value.stream))
+        ? JSON.stringify(JSON.parse(advancedStreamText.value))
         : (inbound.value.stream?.sockopt
           ? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
           : '');
     } catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return; }
     try {
-      sniffing = JSON.stringify(JSON.parse(advancedJson.value.sniffing || inbound.value.sniffing.toString()));
+      sniffing = JSON.stringify(JSON.parse(advancedSniffingText.value || inbound.value.sniffing.toString()));
     } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; }
     try {
-      settings = JSON.stringify(JSON.parse(advancedJson.value.settings || inbound.value.settings.toString()));
+      settings = JSON.stringify(JSON.parse(advancedSettingsText.value || inbound.value.settings.toString()));
     } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; }
 
     // The structured form mutates `inbound.stream` directly when the
@@ -598,7 +799,7 @@ watch(
   () => {
     if (!inbound.value?.stream) return;
     try {
-      advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
+      advancedStreamText.value = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
     } catch (_e) { /* leave as is */ }
   },
 );
@@ -607,7 +808,7 @@ watch(
   () => {
     if (!inbound.value?.sniffing) return;
     try {
-      advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
+      advancedSniffingText.value = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
     } catch (_e) { /* leave as is */ }
   },
 );
@@ -616,7 +817,7 @@ watch(
   () => {
     if (!inbound.value?.settings) return;
     try {
-      advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
+      advancedSettingsText.value = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
     } catch (_e) { /* leave as is */ }
   },
 );
@@ -1005,7 +1206,7 @@ watch(
           <a-form-item>
             <template #label>
               <a-tooltip
-                title='Physical interface for outbound traffic. Use "auto" to detect; auto-enabled when Auto system routes is set.'>
+                title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">
                 Auto outbounds interface
               </a-tooltip>
             </template>
@@ -1834,14 +2035,6 @@ watch(
               </template>
               <a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
             </a-form-item>
-            <a-form-item>
-              <template #label>
-                <a-tooltip title="Obfuscation password. Must match between server and client.">
-                  Obfs password
-                </a-tooltip>
-              </template>
-              <a-input v-model:value="inbound.stream.hysteria.auth" />
-            </a-form-item>
             <a-form-item>
               <template #label>
                 <a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
@@ -1952,20 +2145,48 @@ watch(
 
       <!-- ============================== ADVANCED ============================== -->
       <a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')">
-        <a-alert type="info" show-icon
-          message="Edit raw stream JSON to access advanced fields we don't yet expose through the form."
-          class="mb-12" />
-        <a-form layout="vertical">
-          <a-form-item label="settings (clients, encryption, fallbacks, …)">
-            <JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
-          </a-form-item>
-          <a-form-item label="streamSettings">
-            <JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
-          </a-form-item>
-          <a-form-item label="sniffing (overrides the Sniffing tab when set)">
-            <JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
-          </a-form-item>
-        </a-form>
+        <div class="advanced-shell">
+          <div class="advanced-panel">
+            <div class="advanced-panel__header">
+              <div>
+                <div class="advanced-panel__title">Inbound JSON sections</div>
+                <div class="advanced-panel__subtitle">
+                  Full inbound JSON and focused editors for settings, sniffing, and streamSettings.
+                </div>
+              </div>
+            </div>
+
+            <a-tabs v-model:active-key="advancedSectionKey" class="advanced-inner-tabs">
+              <a-tab-pane key="all" tab="All">
+                <div class="advanced-editor-meta">
+                  Full inbound object with all fields in one editor.
+                </div>
+                <JsonEditor v-model:value="advancedAllConfig" min-height="340px" max-height="560px" />
+              </a-tab-pane>
+              <a-tab-pane key="settings" tab="Settings">
+                <div class="advanced-editor-meta">
+                  Xray settings block wrapper:
+                  <code>{ settings: { ... } }</code>.
+                </div>
+                <JsonEditor v-model:value="advancedSettingsConfig" min-height="320px" max-height="540px" />
+              </a-tab-pane>
+              <a-tab-pane key="sniffingSection" tab="Sniffing">
+                <div class="advanced-editor-meta">
+                  Xray sniffing block wrapper:
+                  <code>{ sniffing: { ... } }</code>.
+                </div>
+                <JsonEditor v-model:value="advancedSniffingConfig" min-height="240px" max-height="420px" />
+              </a-tab-pane>
+              <a-tab-pane key="streamSection" tab="Stream">
+                <div class="advanced-editor-meta">
+                  Xray stream block wrapper:
+                  <code>{ streamSettings: { ... } }</code>.
+                </div>
+                <JsonEditor v-model:value="advancedStreamConfig" min-height="320px" max-height="540px" />
+              </a-tab-pane>
+            </a-tabs>
+          </div>
+        </div>
       </a-tab-pane>
     </a-tabs>
   </a-modal>
@@ -2041,6 +2262,73 @@ watch(
   margin-top: 4px;
 }
 
+.advanced-shell {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.advanced-panel {
+  padding: 14px;
+  border: 1px solid rgba(128, 128, 128, 0.18);
+  border-radius: 12px;
+  background: rgba(128, 128, 128, 0.04);
+}
+
+.advanced-panel__header {
+  margin-bottom: 12px;
+}
+
+.advanced-panel__title {
+  font-size: 14px;
+  font-weight: 600;
+  line-height: 1.4;
+}
+
+.advanced-panel__subtitle {
+  margin-top: 4px;
+  color: rgba(0, 0, 0, 0.6);
+  line-height: 1.5;
+}
+
+.advanced-inner-tabs :deep(.ant-tabs-nav) {
+  margin-bottom: 12px;
+}
+
+.advanced-inner-tabs :deep(.ant-tabs-tab) {
+  padding-inline: 14px;
+}
+
+.advanced-editor-meta {
+  margin-bottom: 10px;
+  color: rgba(0, 0, 0, 0.65);
+  line-height: 1.5;
+}
+
+@media (max-width: 768px) {
+  .advanced-panel {
+    padding: 12px;
+    border-radius: 10px;
+  }
+
+  .advanced-inner-tabs :deep(.ant-tabs-tab) {
+    padding-inline: 10px;
+  }
+}
+
+:global(.dark) .advanced-panel__subtitle,
+:global(.dark) .advanced-editor-meta,
+:global(.ultra) .advanced-panel__subtitle,
+:global(.ultra) .advanced-editor-meta {
+  color: rgba(255, 255, 255, 0.65);
+}
+
+:global(.dark) .advanced-panel,
+:global(.ultra) .advanced-panel {
+  border-color: rgba(255, 255, 255, 0.12);
+  background: rgba(255, 255, 255, 0.03);
+}
+
 .section-heading {
   font-weight: 500;
   margin: 12px 0 6px;

+ 65 - 4
frontend/src/pages/inbounds/QrPanel.vue

@@ -1,6 +1,7 @@
 <script setup>
+import { ref } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
+import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons-vue';
 import { message } from 'ant-design-vue';
 
 import { ClipboardManager, FileManager } from '@/utils';
@@ -15,6 +16,8 @@ const props = defineProps({
   showQr: { type: Boolean, default: true },
 });
 
+const qrRef = ref(null);
+
 async function copy() {
   const ok = await ClipboardManager.copyText(props.value);
   if (ok) message.success(t('copied'));
@@ -24,6 +27,55 @@ function download() {
   if (!props.downloadName) return;
   FileManager.downloadTextFile(props.value, props.downloadName);
 }
+
+function svgToPngBlob(size = 360) {
+  const svgEl = qrRef.value?.querySelector('svg');
+  if (!svgEl) return Promise.resolve(null);
+  const svgData = new XMLSerializer().serializeToString(svgEl);
+  const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
+  const url = URL.createObjectURL(svgBlob);
+  return new Promise((resolve) => {
+    const img = new Image();
+    img.onload = () => {
+      const canvas = document.createElement('canvas');
+      canvas.width = size;
+      canvas.height = size;
+      const ctx = canvas.getContext('2d');
+      ctx.fillStyle = '#ffffff';
+      ctx.fillRect(0, 0, size, size);
+      ctx.drawImage(img, 0, 0, size, size);
+      URL.revokeObjectURL(url);
+      canvas.toBlob(resolve, 'image/png');
+    };
+    img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
+    img.src = url;
+  });
+}
+
+async function copyImage() {
+  const blob = await svgToPngBlob(props.size);
+  if (!blob) return;
+  try {
+    await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
+    message.success(t('copied'));
+  } catch {
+    downloadImageBlob(blob);
+  }
+}
+
+function downloadImageBlob(blob) {
+  const url = URL.createObjectURL(blob);
+  const link = document.createElement('a');
+  link.href = url;
+  link.download = `${props.remark || 'qrcode'}.png`;
+  link.click();
+  URL.revokeObjectURL(url);
+}
+
+async function downloadImage() {
+  const blob = await svgToPngBlob(props.size);
+  if (blob) downloadImageBlob(blob);
+}
 </script>
 
 <template>
@@ -37,6 +89,13 @@ function download() {
           </template>
         </a-button>
       </a-tooltip>
+      <a-tooltip v-if="showQr" :title="t('downloadImage', 'Download Image')">
+        <a-button size="small" @click="downloadImage">
+          <template #icon>
+            <PictureOutlined />
+          </template>
+        </a-button>
+      </a-tooltip>
       <a-tooltip v-if="downloadName" :title="t('download')">
         <a-button size="small" @click="download">
           <template #icon>
@@ -45,9 +104,11 @@ function download() {
         </a-button>
       </a-tooltip>
     </div>
-    <div v-if="showQr" class="qr-panel-canvas">
-      <a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
-        color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
+    <div v-if="showQr" ref="qrRef" class="qr-panel-canvas">
+      <a-tooltip :title="t('copy')">
+        <a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false" color="#000000"
+          bg-color="#ffffff" @click="copyImage" />
+      </a-tooltip>
     </div>
   </div>
 </template>