XrayPage.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. <script setup>
  2. import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { Modal, message } from 'ant-design-vue';
  5. import {
  6. SettingOutlined,
  7. SwapOutlined,
  8. UploadOutlined,
  9. ClusterOutlined,
  10. DatabaseOutlined,
  11. CodeOutlined,
  12. QuestionCircleOutlined,
  13. } from '@ant-design/icons-vue';
  14. import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
  15. import { useMediaQuery } from '@/composables/useMediaQuery.js';
  16. import AppSidebar from '@/components/AppSidebar.vue';
  17. import BasicsTab from './BasicsTab.vue';
  18. import RoutingTab from './RoutingTab.vue';
  19. import OutboundsTab from './OutboundsTab.vue';
  20. import BalancersTab from './BalancersTab.vue';
  21. import DnsTab from './DnsTab.vue';
  22. import WarpModal from './WarpModal.vue';
  23. import NordModal from './NordModal.vue';
  24. import JsonEditor from '@/components/JsonEditor.vue';
  25. import { useXraySetting } from './useXraySetting.js';
  26. import { useWebSocket } from '@/composables/useWebSocket.js';
  27. const { t } = useI18n();
  28. const {
  29. fetched,
  30. spinning,
  31. saveDisabled,
  32. fetchError,
  33. xraySetting,
  34. templateSettings,
  35. outboundTestUrl,
  36. inboundTags,
  37. clientReverseTags,
  38. restartResult,
  39. outboundsTraffic,
  40. outboundTestStates,
  41. testingAll,
  42. fetchAll,
  43. resetOutboundsTraffic,
  44. testOutbound,
  45. testAllOutbounds,
  46. saveAll,
  47. resetToDefault,
  48. restartXray,
  49. applyOutboundsEvent,
  50. } = useXraySetting();
  51. useWebSocket({ outbounds: applyOutboundsEvent });
  52. async function onTestOutbound(idx, mode = 'tcp') {
  53. const outbound = templateSettings.value?.outbounds?.[idx];
  54. if (outbound) await testOutbound(idx, outbound, mode);
  55. }
  56. async function onTestAllOutbounds(mode = 'tcp') {
  57. await testAllOutbounds(mode);
  58. }
  59. function onDeleteOutbound(idx) {
  60. templateSettings.value.outbounds.splice(idx, 1);
  61. outboundTestStates.value = Object.fromEntries(
  62. Object.entries(outboundTestStates.value)
  63. .filter(([k]) => Number(k) !== idx)
  64. .map(([k, v]) => [Number(k) > idx ? Number(k) - 1 : Number(k), v]),
  65. );
  66. }
  67. // === Advanced tab — radio-driven view ==============================
  68. // Mirrors the legacy advanced page: a 4-way radio toggles which slice
  69. // of the xray config the textarea edits — the full config, just the
  70. // inbounds, just the outbounds, or just the routing rules. Each slice
  71. // reads/writes through templateSettings so edits propagate to the
  72. // dirty-poll and structured tabs.
  73. const advSettings = ref('xraySetting');
  74. const advancedText = computed({
  75. get: () => {
  76. if (advSettings.value === 'xraySetting') return xraySetting.value;
  77. const t = templateSettings.value;
  78. if (!t) return '';
  79. try {
  80. switch (advSettings.value) {
  81. case 'inboundSettings':
  82. return JSON.stringify(t.inbounds || [], null, 2);
  83. case 'outboundSettings':
  84. return JSON.stringify(t.outbounds || [], null, 2);
  85. case 'routingRuleSettings':
  86. return JSON.stringify(t.routing?.rules || [], null, 2);
  87. default:
  88. return '';
  89. }
  90. } catch (_e) {
  91. return '';
  92. }
  93. },
  94. set: (next) => {
  95. if (advSettings.value === 'xraySetting') {
  96. xraySetting.value = next;
  97. return;
  98. }
  99. // Slice edits: parse-then-merge into templateSettings so the
  100. // structured tabs and the dirty-poll re-stringify it cleanly.
  101. let parsed;
  102. try { parsed = JSON.parse(next); } catch (_e) { return; }
  103. const t = templateSettings.value;
  104. if (!t) return;
  105. switch (advSettings.value) {
  106. case 'inboundSettings':
  107. t.inbounds = parsed;
  108. break;
  109. case 'outboundSettings':
  110. t.outbounds = parsed;
  111. break;
  112. case 'routingRuleSettings':
  113. if (!t.routing) t.routing = {};
  114. t.routing.rules = parsed;
  115. break;
  116. }
  117. },
  118. });
  119. // `WarpExist` / `NordExist` derive from the parsed templateSettings —
  120. // the Basics tab gates its WARP / NordVPN domain selectors on whether
  121. // the matching outbound is provisioned, falling back to a "configure"
  122. // button that today just toasts (the modals land in 6-v).
  123. const warpExist = computed(
  124. () => !!templateSettings.value?.outbounds?.find((o) => o?.tag === 'warp'),
  125. );
  126. const nordExist = computed(
  127. () => !!templateSettings.value?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-')),
  128. );
  129. // === WARP / NordVPN provisioning modals ============================
  130. const warpOpen = ref(false);
  131. const nordOpen = ref(false);
  132. function showWarp() { warpOpen.value = true; }
  133. function showNord() { nordOpen.value = true; }
  134. function ensureOutbounds() {
  135. if (!templateSettings.value) return null;
  136. if (!Array.isArray(templateSettings.value.outbounds)) {
  137. templateSettings.value.outbounds = [];
  138. }
  139. return templateSettings.value.outbounds;
  140. }
  141. function onAddOutbound(outbound) {
  142. const list = ensureOutbounds();
  143. if (list) list.push(outbound);
  144. }
  145. function onResetOutbound({ index, outbound, oldTag, newTag }) {
  146. const list = ensureOutbounds();
  147. if (!list || index < 0) return;
  148. list[index] = outbound;
  149. // Tag rename across routing rules — preserves Nord's
  150. // server-switch flow without dangling references.
  151. if (oldTag && newTag && oldTag !== newTag) {
  152. const rules = templateSettings.value?.routing?.rules || [];
  153. for (const r of rules) {
  154. if (r?.outboundTag === oldTag) r.outboundTag = newTag;
  155. }
  156. }
  157. }
  158. function onRemoveOutboundByTag(tag) {
  159. const list = ensureOutbounds();
  160. if (!list) return;
  161. const idx = list.findIndex((o) => o?.tag === tag);
  162. if (idx >= 0) list.splice(idx, 1);
  163. }
  164. function onRemoveOutboundByIndex(index) {
  165. const list = ensureOutbounds();
  166. if (list && index >= 0) list.splice(index, 1);
  167. }
  168. function onRemoveRoutingRules({ prefix }) {
  169. const rules = templateSettings.value?.routing?.rules;
  170. if (!Array.isArray(rules)) return;
  171. templateSettings.value.routing.rules = rules.filter(
  172. (r) => !r?.outboundTag?.startsWith?.(prefix),
  173. );
  174. }
  175. const { isMobile } = useMediaQuery();
  176. const basePath = window.X_UI_BASE_PATH || '';
  177. const requestUri = window.location.pathname;
  178. // See SettingsPage scrollTarget — wrap so `document` is in scope.
  179. function scrollTarget() {
  180. return document.getElementById('content-layout');
  181. }
  182. function confirmRestart() {
  183. Modal.confirm({
  184. title: 'Restart xray?',
  185. content: 'Reloads the xray service with the saved configuration.',
  186. okText: 'Restart',
  187. cancelText: 'Cancel',
  188. onOk: () => restartXray(),
  189. });
  190. }
  191. const tabKeys = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
  192. const slugByKey = {
  193. 'tpl-basic': 'basic',
  194. 'tpl-routing': 'routing',
  195. 'tpl-outbound': 'outbound',
  196. 'tpl-balancer': 'balancer',
  197. 'tpl-dns': 'dns',
  198. 'tpl-advanced': 'advanced',
  199. };
  200. const keyBySlug = Object.fromEntries(Object.entries(slugByKey).map(([k, v]) => [v, k]));
  201. const activeTabKey = ref(keyBySlug[window.location.hash.slice(1)] || tabKeys[0]);
  202. function onTabChange(key) {
  203. activeTabKey.value = key;
  204. const slug = slugByKey[key];
  205. if (slug && window.location.hash !== `#${slug}`) {
  206. history.replaceState(null, '', `#${slug}`);
  207. }
  208. }
  209. function onSaveAll() {
  210. try {
  211. JSON.parse(xraySetting.value);
  212. } catch (e) {
  213. message.error(`Advanced JSON: ${e.message}`);
  214. activeTabKey.value = 'tpl-advanced';
  215. return;
  216. }
  217. saveAll();
  218. }
  219. function syncTabFromHash() {
  220. const key = keyBySlug[window.location.hash.slice(1)];
  221. if (key) activeTabKey.value = key;
  222. }
  223. onMounted(() => {
  224. window.addEventListener('hashchange', syncTabFromHash);
  225. });
  226. onBeforeUnmount(() => {
  227. window.removeEventListener('hashchange', syncTabFromHash);
  228. });
  229. </script>
  230. <template>
  231. <a-config-provider :theme="antdThemeConfig">
  232. <a-layout class="xray-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
  233. <AppSidebar :base-path="basePath" :request-uri="requestUri" />
  234. <a-layout class="content-shell">
  235. <a-layout-content id="content-layout" class="content-area">
  236. <a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
  237. <div v-if="!fetched" class="loading-spacer" />
  238. <a-result v-else-if="fetchError" status="error" :title="t('somethingWentWrong')" :sub-title="fetchError">
  239. <template #extra>
  240. <a-button type="primary" @click="fetchAll">{{ t('check') }}</a-button>
  241. </template>
  242. </a-result>
  243. <template v-else>
  244. <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
  245. <!-- Save / Restart bar -->
  246. <a-col :span="24">
  247. <a-card hoverable>
  248. <a-row class="header-row">
  249. <a-col :xs="24" :sm="14" class="header-actions">
  250. <a-space direction="horizontal">
  251. <a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
  252. {{ t('pages.xray.save') }}
  253. </a-button>
  254. <a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
  255. {{ t('pages.xray.restart') }}
  256. </a-button>
  257. <a-popover v-if="restartResult" placement="rightTop">
  258. <template #title>Xray restart output</template>
  259. <template #content>
  260. <pre class="restart-result">{{ restartResult }}</pre>
  261. </template>
  262. <QuestionCircleOutlined class="restart-icon" />
  263. </a-popover>
  264. </a-space>
  265. </a-col>
  266. <a-col :xs="24" :sm="10" class="header-info">
  267. <a-back-top :target="scrollTarget" :visibility-height="200" />
  268. <a-alert type="warning" show-icon :message="t('pages.settings.infoDesc')" />
  269. </a-col>
  270. </a-row>
  271. </a-card>
  272. </a-col>
  273. <!-- Tabs -->
  274. <a-col :span="24">
  275. <a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
  276. <a-tab-pane key="tpl-basic" class="tab-pane">
  277. <template #tab>
  278. <a-tooltip :title="isMobile ? t('pages.xray.basicTemplate') : null">
  279. <SettingOutlined />
  280. </a-tooltip>
  281. <span v-if="!isMobile">{{ t('pages.xray.basicTemplate') }}</span>
  282. </template>
  283. <BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
  284. :warp-exist="warpExist" :nord-exist="nordExist"
  285. @update:outbound-test-url="(v) => (outboundTestUrl = v)" @show-warp="showWarp"
  286. @show-nord="showNord" @reset-default="resetToDefault" />
  287. </a-tab-pane>
  288. <a-tab-pane key="tpl-routing" class="tab-pane">
  289. <template #tab>
  290. <a-tooltip :title="isMobile ? t('pages.xray.Routings') : null">
  291. <SwapOutlined />
  292. </a-tooltip>
  293. <span v-if="!isMobile">{{ t('pages.xray.Routings') }}</span>
  294. </template>
  295. <RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
  296. :client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
  297. </a-tab-pane>
  298. <a-tab-pane key="tpl-outbound" class="tab-pane">
  299. <template #tab>
  300. <a-tooltip :title="isMobile ? t('pages.xray.Outbounds') : null">
  301. <UploadOutlined />
  302. </a-tooltip>
  303. <span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
  304. </template>
  305. <OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
  306. :outbound-test-states="outboundTestStates" :testing-all="testingAll"
  307. :inbound-tags="inboundTags" :is-mobile="isMobile"
  308. @reset-traffic="resetOutboundsTraffic" @test="onTestOutbound"
  309. @test-all="onTestAllOutbounds" @delete="onDeleteOutbound"
  310. @show-warp="showWarp" @show-nord="showNord" />
  311. </a-tab-pane>
  312. <a-tab-pane key="tpl-balancer" class="tab-pane">
  313. <template #tab>
  314. <a-tooltip :title="isMobile ? t('pages.xray.Balancers') : null">
  315. <ClusterOutlined />
  316. </a-tooltip>
  317. <span v-if="!isMobile">{{ t('pages.xray.Balancers') }}</span>
  318. </template>
  319. <BalancersTab :template-settings="templateSettings"
  320. :client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
  321. </a-tab-pane>
  322. <a-tab-pane key="tpl-dns" class="tab-pane">
  323. <template #tab>
  324. <a-tooltip :title="isMobile ? 'DNS' : null">
  325. <DatabaseOutlined />
  326. </a-tooltip>
  327. <span v-if="!isMobile">DNS</span>
  328. </template>
  329. <DnsTab :template-settings="templateSettings" />
  330. </a-tab-pane>
  331. <a-tab-pane key="tpl-advanced" class="tab-pane">
  332. <template #tab>
  333. <a-tooltip :title="isMobile ? t('pages.xray.advancedTemplate') : null">
  334. <CodeOutlined />
  335. </a-tooltip>
  336. <span v-if="!isMobile">{{ t('pages.xray.advancedTemplate') }}</span>
  337. </template>
  338. <a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
  339. <a-radio-group v-model:value="advSettings" button-style="solid"
  340. :size="isMobile ? 'small' : 'middle'" :style="{ margin: '12px 0' }">
  341. <a-radio-button value="xraySetting">{{ t('pages.xray.completeTemplate') }}</a-radio-button>
  342. <a-radio-button value="inboundSettings">{{ t('pages.xray.Inbounds') }}</a-radio-button>
  343. <a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
  344. <a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
  345. </a-radio-group>
  346. <JsonEditor v-model:value="advancedText" min-height="420px" max-height="720px" />
  347. </a-tab-pane>
  348. </a-tabs>
  349. </a-col>
  350. </a-row>
  351. </template>
  352. </a-spin>
  353. </a-layout-content>
  354. </a-layout>
  355. <WarpModal v-model:open="warpOpen" :template-settings="templateSettings" @add-outbound="onAddOutbound"
  356. @reset-outbound="onResetOutbound" @remove-outbound="onRemoveOutboundByTag" />
  357. <NordModal v-model:open="nordOpen" :template-settings="templateSettings" @add-outbound="onAddOutbound"
  358. @reset-outbound="onResetOutbound" @remove-outbound="onRemoveOutboundByIndex"
  359. @remove-routing-rules="onRemoveRoutingRules" />
  360. </a-layout>
  361. </a-config-provider>
  362. </template>
  363. <style scoped>
  364. .xray-page {
  365. --bg-page: #e6e8ec;
  366. --bg-card: #ffffff;
  367. min-height: 100vh;
  368. background: var(--bg-page);
  369. }
  370. .xray-page.is-dark {
  371. --bg-page: #1e1e1e;
  372. --bg-card: #252526;
  373. }
  374. .xray-page.is-dark.is-ultra {
  375. --bg-page: #050505;
  376. --bg-card: #0c0e12;
  377. }
  378. .xray-page :deep(.ant-layout),
  379. .xray-page :deep(.ant-layout-content) {
  380. background: transparent;
  381. }
  382. .content-shell {
  383. background: transparent;
  384. }
  385. .content-area {
  386. padding: 24px;
  387. }
  388. .loading-spacer {
  389. min-height: calc(100vh - 120px);
  390. }
  391. .header-row {
  392. display: flex;
  393. flex-wrap: wrap;
  394. align-items: center;
  395. }
  396. .header-actions {
  397. padding: 4px;
  398. }
  399. .header-info {
  400. display: flex;
  401. justify-content: flex-end;
  402. }
  403. .tab-pane {
  404. padding-top: 20px;
  405. }
  406. .restart-icon {
  407. font-size: 16px;
  408. cursor: pointer;
  409. color: var(--ant-primary-color, #1890ff);
  410. }
  411. .restart-result {
  412. max-width: 480px;
  413. white-space: pre-wrap;
  414. font-size: 12px;
  415. margin: 0;
  416. }
  417. .icons-only :deep(.ant-tabs-nav) {
  418. margin-bottom: 8px;
  419. }
  420. .icons-only :deep(.ant-tabs-nav-wrap) {
  421. width: 100%;
  422. }
  423. .icons-only :deep(.ant-tabs-nav-list) {
  424. display: flex;
  425. width: 100%;
  426. }
  427. .icons-only :deep(.ant-tabs-tab) {
  428. flex: 1 1 0;
  429. justify-content: center;
  430. margin: 0;
  431. padding: 10px 0;
  432. }
  433. .icons-only :deep(.ant-tabs-tab .anticon) {
  434. margin: 0;
  435. font-size: 18px;
  436. }
  437. .icons-only :deep(.ant-tabs-nav-operations) {
  438. display: none;
  439. }
  440. </style>