3x-ui/frontend/src/pages/xray/XrayPage.vue
MHSanaei 5543466fcc
fix(forms): validate JSON tabs before applying or saving
InboundFormModal: switching out of the Advanced tab now parses the three
JSON textareas and rebuilds the structured Inbound via Inbound.fromJson,
so the Basic tab reflects what was pasted. Invalid JSON keeps the user
on Advanced with a specific parse error.

XrayPage: Save now parses xraySetting upfront and snaps the user back to
the Advanced tab on invalid JSON instead of letting the backend reject a
generic blob.
2026-05-13 19:01:12 +02:00

453 lines
15 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
SettingOutlined,
SwapOutlined,
UploadOutlined,
ClusterOutlined,
DatabaseOutlined,
CodeOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import BasicsTab from './BasicsTab.vue';
import RoutingTab from './RoutingTab.vue';
import OutboundsTab from './OutboundsTab.vue';
import BalancersTab from './BalancersTab.vue';
import DnsTab from './DnsTab.vue';
import WarpModal from './WarpModal.vue';
import NordModal from './NordModal.vue';
import { useXraySetting } from './useXraySetting.js';
import { useWebSocket } from '@/composables/useWebSocket.js';
const { t } = useI18n();
const {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
templateSettings,
outboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
resetOutboundsTraffic,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
applyOutboundsEvent,
} = useXraySetting();
useWebSocket({ outbounds: applyOutboundsEvent });
async function onTestOutbound(idx, mode = 'tcp') {
const outbound = templateSettings.value?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound, mode);
}
async function onTestAllOutbounds(mode = 'tcp') {
await testAllOutbounds(mode);
}
function onDeleteOutbound(idx) {
templateSettings.value.outbounds.splice(idx, 1);
outboundTestStates.value = Object.fromEntries(
Object.entries(outboundTestStates.value)
.filter(([k]) => Number(k) !== idx)
.map(([k, v]) => [Number(k) > idx ? Number(k) - 1 : Number(k), v]),
);
}
// === Advanced tab — radio-driven view ==============================
// Mirrors the legacy advanced page: a 4-way radio toggles which slice
// of the xray config the textarea edits — the full config, just the
// inbounds, just the outbounds, or just the routing rules. Each slice
// reads/writes through templateSettings so edits propagate to the
// dirty-poll and structured tabs.
const advSettings = ref('xraySetting');
const advancedText = computed({
get: () => {
if (advSettings.value === 'xraySetting') return xraySetting.value;
const t = templateSettings.value;
if (!t) return '';
try {
switch (advSettings.value) {
case 'inboundSettings':
return JSON.stringify(t.inbounds || [], null, 2);
case 'outboundSettings':
return JSON.stringify(t.outbounds || [], null, 2);
case 'routingRuleSettings':
return JSON.stringify(t.routing?.rules || [], null, 2);
default:
return '';
}
} catch (_e) {
return '';
}
},
set: (next) => {
if (advSettings.value === 'xraySetting') {
xraySetting.value = next;
return;
}
// Slice edits: parse-then-merge into templateSettings so the
// structured tabs and the dirty-poll re-stringify it cleanly.
let parsed;
try { parsed = JSON.parse(next); } catch (_e) { return; }
const t = templateSettings.value;
if (!t) return;
switch (advSettings.value) {
case 'inboundSettings':
t.inbounds = parsed;
break;
case 'outboundSettings':
t.outbounds = parsed;
break;
case 'routingRuleSettings':
if (!t.routing) t.routing = {};
t.routing.rules = parsed;
break;
}
},
});
// `WarpExist` / `NordExist` derive from the parsed templateSettings —
// the Basics tab gates its WARP / NordVPN domain selectors on whether
// the matching outbound is provisioned, falling back to a "configure"
// button that today just toasts (the modals land in 6-v).
const warpExist = computed(
() => !!templateSettings.value?.outbounds?.find((o) => o?.tag === 'warp'),
);
const nordExist = computed(
() => !!templateSettings.value?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-')),
);
// === WARP / NordVPN provisioning modals ============================
const warpOpen = ref(false);
const nordOpen = ref(false);
function showWarp() { warpOpen.value = true; }
function showNord() { nordOpen.value = true; }
function ensureOutbounds() {
if (!templateSettings.value) return null;
if (!Array.isArray(templateSettings.value.outbounds)) {
templateSettings.value.outbounds = [];
}
return templateSettings.value.outbounds;
}
function onAddOutbound(outbound) {
const list = ensureOutbounds();
if (list) list.push(outbound);
}
function onResetOutbound({ index, outbound, oldTag, newTag }) {
const list = ensureOutbounds();
if (!list || index < 0) return;
list[index] = outbound;
// Tag rename across routing rules — preserves Nord's
// server-switch flow without dangling references.
if (oldTag && newTag && oldTag !== newTag) {
const rules = templateSettings.value?.routing?.rules || [];
for (const r of rules) {
if (r?.outboundTag === oldTag) r.outboundTag = newTag;
}
}
}
function onRemoveOutboundByTag(tag) {
const list = ensureOutbounds();
if (!list) return;
const idx = list.findIndex((o) => o?.tag === tag);
if (idx >= 0) list.splice(idx, 1);
}
function onRemoveOutboundByIndex(index) {
const list = ensureOutbounds();
if (list && index >= 0) list.splice(index, 1);
}
function onRemoveRoutingRules({ prefix }) {
const rules = templateSettings.value?.routing?.rules;
if (!Array.isArray(rules)) return;
templateSettings.value.routing.rules = rules.filter(
(r) => !r?.outboundTag?.startsWith?.(prefix),
);
}
const { isMobile } = useMediaQuery();
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// See SettingsPage scrollTarget — wrap so `document` is in scope.
function scrollTarget() {
return document.getElementById('content-layout');
}
function confirmRestart() {
Modal.confirm({
title: 'Restart xray?',
content: 'Reloads the xray service with the saved configuration.',
okText: 'Restart',
cancelText: 'Cancel',
onOk: () => restartXray(),
});
}
const tabKeys = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
const slugByKey = {
'tpl-basic': 'basic',
'tpl-routing': 'routing',
'tpl-outbound': 'outbound',
'tpl-balancer': 'balancer',
'tpl-dns': 'dns',
'tpl-advanced': 'advanced',
};
const keyBySlug = Object.fromEntries(Object.entries(slugByKey).map(([k, v]) => [v, k]));
const activeTabKey = ref(keyBySlug[window.location.hash.slice(1)] || tabKeys[0]);
function onTabChange(key) {
activeTabKey.value = key;
const slug = slugByKey[key];
if (slug && window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
function onSaveAll() {
try {
JSON.parse(xraySetting.value);
} catch (e) {
message.error(`Advanced JSON: ${e.message}`);
activeTabKey.value = 'tpl-advanced';
return;
}
saveAll();
}
function syncTabFromHash() {
const key = keyBySlug[window.location.hash.slice(1)];
if (key) activeTabKey.value = key;
}
onMounted(() => {
window.addEventListener('hashchange', syncTabFromHash);
});
onBeforeUnmount(() => {
window.removeEventListener('hashchange', syncTabFromHash);
});
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<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">
<a-layout-content id="content-layout" class="content-area">
<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">
<template #extra>
<a-button type="primary" @click="fetchAll">{{ t('check') }}</a-button>
</template>
</a-result>
<template v-else>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<!-- Save / Restart bar -->
<a-col :span="24">
<a-card hoverable>
<a-row class="header-row">
<a-col :xs="24" :sm="14" class="header-actions">
<a-space direction="horizontal">
<a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
{{ t('pages.xray.save') }}
</a-button>
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
{{ t('pages.xray.restart') }}
</a-button>
<a-popover v-if="restartResult" placement="rightTop">
<template #title>Xray restart output</template>
<template #content>
<pre class="restart-result">{{ restartResult }}</pre>
</template>
<QuestionCircleOutlined class="restart-icon" />
</a-popover>
</a-space>
</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-col>
</a-row>
</a-card>
</a-col>
<!-- Tabs -->
<a-col :span="24">
<a-tabs :active-key="activeTabKey" @change="onTabChange">
<a-tab-pane key="tpl-basic" class="tab-pane">
<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" />
</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" />
</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" :testing-all="testingAll"
:inbound-tags="inboundTags" :is-mobile="isMobile"
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound"
@test-all="onTestAllOutbounds" @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" />
</a-tab-pane>
<a-tab-pane key="tpl-dns" class="tab-pane">
<template #tab>
<DatabaseOutlined /> <span>DNS</span>
</template>
<DnsTab :template-settings="templateSettings" />
</a-tab-pane>
<a-tab-pane key="tpl-advanced" class="tab-pane">
<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-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-tab-pane>
</a-tabs>
</a-col>
</a-row>
</template>
</a-spin>
</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" />
</a-layout>
</a-config-provider>
</template>
<style scoped>
.xray-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.xray-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.xray-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.xray-page :deep(.ant-layout),
.xray-page :deep(.ant-layout-content) {
background: transparent;
}
.content-shell {
background: transparent;
}
.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-info {
display: flex;
justify-content: flex-end;
}
.tab-pane {
padding-top: 20px;
}
.restart-icon {
font-size: 16px;
cursor: pointer;
color: var(--ant-primary-color, #1890ff);
}
.restart-result {
max-width: 480px;
white-space: pre-wrap;
font-size: 12px;
margin: 0;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
</style>