1
0

XrayPage.vue 15 KB

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