diff --git a/frontend/src/pages/xray/XrayPage.vue b/frontend/src/pages/xray/XrayPage.vue
new file mode 100644
index 00000000..140a3166
--- /dev/null
+++ b/frontend/src/pages/xray/XrayPage.vue
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic template
+
+
+
+
+
+
+ Routing
+
+
+
+
+
+
+ Outbounds
+
+
+
+
+
+
+ Balancers
+
+
+
+
+
+
+ DNS
+
+
+
+
+
+
+ Advanced (JSON)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/xray/useXraySetting.js b/frontend/src/pages/xray/useXraySetting.js
new file mode 100644
index 00000000..be985397
--- /dev/null
+++ b/frontend/src/pages/xray/useXraySetting.js
@@ -0,0 +1,109 @@
+// Drives the xray page's fetch / dirty / save lifecycle. The Go side
+// returns the live xraySetting (the full JSON config), the inboundTags
+// list, and a few sidecar values (clientReverseTags, outboundTestUrl)
+// the structured tabs need. We keep the JSON as a string here — pretty-
+// printed for the textarea; tabs that want a parsed view can JSON.parse
+// it themselves.
+
+import { onMounted, onUnmounted, ref } from 'vue';
+import { HttpUtil, PromiseUtil } from '@/utils';
+
+const DIRTY_POLL_MS = 1000;
+
+export function useXraySetting() {
+ const fetched = ref(false);
+ const spinning = ref(false);
+ const saveDisabled = ref(true);
+
+ const xraySetting = ref('');
+ const oldXraySetting = ref('');
+
+ const outboundTestUrl = ref('https://www.google.com/generate_204');
+ const oldOutboundTestUrl = ref('');
+
+ const inboundTags = ref([]);
+ const clientReverseTags = ref([]);
+ const restartResult = ref('');
+
+ async function fetchAll() {
+ const msg = await HttpUtil.post('/panel/xray/');
+ if (!msg?.success) return;
+ const obj = JSON.parse(msg.obj);
+ const pretty = JSON.stringify(obj.xraySetting, null, 2);
+ xraySetting.value = pretty;
+ oldXraySetting.value = pretty;
+ inboundTags.value = obj.inboundTags || [];
+ clientReverseTags.value = obj.clientReverseTags || [];
+ outboundTestUrl.value = obj.outboundTestUrl || 'https://www.google.com/generate_204';
+ oldOutboundTestUrl.value = outboundTestUrl.value;
+ fetched.value = true;
+ saveDisabled.value = true;
+ }
+
+ async function saveAll() {
+ spinning.value = true;
+ try {
+ const msg = await HttpUtil.post('/panel/xray/update', {
+ xraySetting: xraySetting.value,
+ outboundTestUrl: outboundTestUrl.value || 'https://www.google.com/generate_204',
+ });
+ if (msg?.success) await fetchAll();
+ } finally {
+ spinning.value = false;
+ }
+ }
+
+ async function restartXray() {
+ spinning.value = true;
+ try {
+ const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
+ if (msg?.success) {
+ // Match legacy: short pause, then poll for the result blob so
+ // the popover surfaces any startup error from the new process.
+ await PromiseUtil.sleep(500);
+ const r = await HttpUtil.get('/panel/xray/getXrayResult');
+ if (r?.success) restartResult.value = r.obj || '';
+ }
+ } finally {
+ spinning.value = false;
+ }
+ }
+
+ // Same 1s busy-loop pattern the settings page uses — keep it cheap
+ // and consistent. Real work (the JSON diff) is just a string compare.
+ let timer = null;
+ function startDirtyPoll() {
+ if (timer != null) return;
+ timer = setInterval(() => {
+ saveDisabled.value =
+ oldXraySetting.value === xraySetting.value
+ && oldOutboundTestUrl.value === outboundTestUrl.value;
+ }, DIRTY_POLL_MS);
+ }
+ function stopDirtyPoll() {
+ if (timer != null) {
+ clearInterval(timer);
+ timer = null;
+ }
+ }
+
+ onMounted(() => {
+ fetchAll();
+ startDirtyPoll();
+ });
+ onUnmounted(stopDirtyPoll);
+
+ return {
+ fetched,
+ spinning,
+ saveDisabled,
+ xraySetting,
+ outboundTestUrl,
+ inboundTags,
+ clientReverseTags,
+ restartResult,
+ fetchAll,
+ saveAll,
+ restartXray,
+ };
+}
diff --git a/frontend/src/xray.js b/frontend/src/xray.js
new file mode 100644
index 00000000..679dbd3b
--- /dev/null
+++ b/frontend/src/xray.js
@@ -0,0 +1,16 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import '@/composables/useTheme.js';
+import XrayPage from '@/pages/xray/XrayPage.vue';
+
+setupAxios();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+ message.config({ getContainer: () => messageContainer });
+}
+
+createApp(XrayPage).use(Antd).mount('#app');
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index a4baa643..d5ff27ad 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -19,6 +19,8 @@ const MIGRATED_ROUTES = {
'/panel/settings/': '/settings.html',
'/panel/inbounds': '/inbounds.html',
'/panel/inbounds/': '/inbounds.html',
+ '/panel/xray': '/xray.html',
+ '/panel/xray/': '/xray.html',
};
// Build a proxy config that suppresses ECONNREFUSED noise when the Go
@@ -72,6 +74,7 @@ export default defineConfig({
login: path.resolve(__dirname, 'login.html'),
settings: path.resolve(__dirname, 'settings.html'),
inbounds: path.resolve(__dirname, 'inbounds.html'),
+ xray: path.resolve(__dirname, 'xray.html'),
},
},
},
diff --git a/frontend/xray.html b/frontend/xray.html
new file mode 100644
index 00000000..80915eb7
--- /dev/null
+++ b/frontend/xray.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ 3x-ui · Xray
+
+
+
+
+
+
+