SettingsPage.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. <script setup>
  2. import { computed, onMounted, ref } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { Modal } from 'ant-design-vue';
  5. import {
  6. SettingOutlined,
  7. SafetyOutlined,
  8. MessageOutlined,
  9. CloudServerOutlined,
  10. CodeOutlined,
  11. } from '@ant-design/icons-vue';
  12. import { HttpUtil, PromiseUtil } from '@/utils';
  13. import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
  14. import { useMediaQuery } from '@/composables/useMediaQuery.js';
  15. import AppSidebar from '@/components/AppSidebar.vue';
  16. import { useAllSetting } from './useAllSetting.js';
  17. import GeneralTab from './GeneralTab.vue';
  18. import SecurityTab from './SecurityTab.vue';
  19. import TelegramTab from './TelegramTab.vue';
  20. import SubscriptionGeneralTab from './SubscriptionGeneralTab.vue';
  21. import SubscriptionFormatsTab from './SubscriptionFormatsTab.vue';
  22. const { t } = useI18n();
  23. const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
  24. const { isMobile } = useMediaQuery();
  25. const basePath = window.X_UI_BASE_PATH || '';
  26. const requestUri = window.location.pathname;
  27. // AD-Vue 4's <a-back-top> calls `target()` after mount to find the
  28. // scrolled element. Inline-arrow `() => document.getElementById(...)`
  29. // in the template threw "Cannot read properties of undefined (reading
  30. // 'getElementById')" because of how Vue 3 evaluates the expression
  31. // outside the script-setup scope — wrap in a regular function so
  32. // `document` resolves to the window global at call time.
  33. function scrollTarget() {
  34. return document.getElementById('content-layout');
  35. }
  36. // `entry*` mirrors the URL the user opened the panel with so the page
  37. // can rebuild it after a restart that may change host/port/scheme.
  38. const entryHost = ref('');
  39. const entryPort = ref('');
  40. const entryIsIP = ref(false);
  41. function isIp(h) {
  42. if (typeof h !== 'string') return false;
  43. // IPv4: four dot-separated octets 0-255.
  44. const v4 = h.split('.');
  45. if (v4.length === 4 && v4.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return true;
  46. // IPv6: hex groups, optional single :: compression.
  47. if (!h.includes(':') || h.includes(':::')) return false;
  48. const parts = h.split('::');
  49. if (parts.length > 2) return false;
  50. const split = (s) => (s ? s.split(':').filter(Boolean) : []);
  51. const head = split(parts[0]);
  52. const tail = split(parts[1]);
  53. const valid = (seg) => /^[0-9a-fA-F]{1,4}$/.test(seg);
  54. if (![...head, ...tail].every(valid)) return false;
  55. const groups = head.length + tail.length;
  56. return parts.length === 2 ? groups < 8 : groups === 8;
  57. }
  58. onMounted(() => {
  59. entryHost.value = window.location.hostname;
  60. entryPort.value = window.location.port;
  61. entryIsIP.value = isIp(entryHost.value);
  62. });
  63. // Rebuild the URL after a restart — host/port/scheme may have changed
  64. // (cert toggled on, port edited, base path edited).
  65. function rebuildUrlAfterRestart() {
  66. const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting;
  67. const newProtocol = (webCertFile || webKeyFile) ? 'https:' : 'http:';
  68. let base = webBasePath ? webBasePath.replace(/^\//, '') : '';
  69. if (base && !base.endsWith('/')) base += '/';
  70. if (!entryIsIP.value) {
  71. const url = new URL(window.location.href);
  72. url.pathname = `/${base}panel/settings`;
  73. url.protocol = newProtocol;
  74. return url.toString();
  75. }
  76. let finalHost = entryHost.value;
  77. let finalPort = entryPort.value || '';
  78. if (webDomain && isIp(webDomain)) finalHost = webDomain;
  79. if (webPort && Number(webPort) !== Number(entryPort.value)) finalPort = String(webPort);
  80. const url = new URL(`${newProtocol}//${finalHost}`);
  81. if (finalPort) url.port = finalPort;
  82. url.pathname = `/${base}panel/settings`;
  83. return url.toString();
  84. }
  85. function restartPanel() {
  86. Modal.confirm({
  87. title: t('pages.settings.restartPanel'),
  88. content: t('pages.settings.restartPanelDesc'),
  89. okText: t('pages.settings.restartPanel'),
  90. okButtonProps: { danger: true },
  91. cancelText: t('cancel'),
  92. async onOk() {
  93. spinning.value = true;
  94. try {
  95. const msg = await HttpUtil.post('/panel/setting/restartPanel');
  96. if (!msg?.success) return;
  97. await PromiseUtil.sleep(5000);
  98. window.location.replace(rebuildUrlAfterRestart());
  99. } finally {
  100. spinning.value = false;
  101. }
  102. },
  103. });
  104. }
  105. // Conf alerts mirror the legacy banner — pure derivation off allSetting.
  106. const confAlerts = computed(() => {
  107. const out = [];
  108. if (window.location.protocol !== 'https:') {
  109. out.push('Panel is served over plain HTTP — set up TLS for production.');
  110. }
  111. if (allSetting.webPort === 2053) {
  112. out.push('Default port 2053 is well-known — change it to a random port.');
  113. }
  114. const segs = window.location.pathname.split('/').length < 4;
  115. if (segs && allSetting.webBasePath === '/') {
  116. out.push('Default base path "/" is well-known — change it to a random path.');
  117. }
  118. if (allSetting.subEnable) {
  119. let subPath = allSetting.subPath;
  120. if (allSetting.subURI) {
  121. try { subPath = new URL(allSetting.subURI).pathname; } catch (_e) { }
  122. }
  123. if (subPath === '/sub/') {
  124. out.push('Default subscription path "/sub/" is well-known — change it.');
  125. }
  126. }
  127. if (allSetting.subJsonEnable) {
  128. let p = allSetting.subJsonPath;
  129. if (allSetting.subJsonURI) {
  130. try { p = new URL(allSetting.subJsonURI).pathname; } catch (_e) { }
  131. }
  132. if (p === '/json/') {
  133. out.push('Default JSON subscription path "/json/" is well-known — change it.');
  134. }
  135. }
  136. return out;
  137. });
  138. const alertVisible = ref(true);
  139. </script>
  140. <template>
  141. <a-config-provider :theme="antdThemeConfig">
  142. <a-layout class="settings-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
  143. <AppSidebar :base-path="basePath" :request-uri="requestUri" />
  144. <a-layout class="content-shell">
  145. <a-layout-content id="content-layout" class="content-area">
  146. <a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
  147. <div v-if="!fetched" class="loading-spacer" />
  148. <template v-else>
  149. <a-alert v-if="confAlerts.length > 0 && alertVisible" type="error" show-icon closable class="conf-alert"
  150. @close="alertVisible = false">
  151. <template #message>Security warnings</template>
  152. <template #description>
  153. <b>Your panel may be exposed:</b>
  154. <ul>
  155. <li v-for="(msg, i) in confAlerts" :key="i">{{ msg }}</li>
  156. </ul>
  157. </template>
  158. </a-alert>
  159. <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
  160. <a-col :span="24">
  161. <a-card hoverable>
  162. <a-row class="header-row">
  163. <a-col :xs="24" :sm="10" class="header-actions">
  164. <a-space direction="horizontal">
  165. <a-button type="primary" :disabled="saveDisabled" @click="saveAll">
  166. {{ t('pages.settings.save') }}
  167. </a-button>
  168. <a-button type="primary" danger :disabled="!saveDisabled" @click="restartPanel">
  169. {{ t('pages.settings.restartPanel') }}
  170. </a-button>
  171. </a-space>
  172. </a-col>
  173. <a-col :xs="24" :sm="14" class="header-info">
  174. <a-back-top :target="scrollTarget" :visibility-height="200" />
  175. <a-alert type="warning" show-icon :message="t('pages.settings.infoDesc')" />
  176. </a-col>
  177. </a-row>
  178. </a-card>
  179. </a-col>
  180. <a-col :span="24">
  181. <a-tabs default-active-key="1">
  182. <a-tab-pane key="1" class="tab-pane">
  183. <template #tab>
  184. <SettingOutlined />
  185. <span>{{ t('pages.settings.panelSettings') }}</span>
  186. </template>
  187. <GeneralTab :all-setting="allSetting" />
  188. </a-tab-pane>
  189. <a-tab-pane key="2" class="tab-pane">
  190. <template #tab>
  191. <SafetyOutlined />
  192. <span>{{ t('pages.settings.securitySettings') }}</span>
  193. </template>
  194. <SecurityTab :all-setting="allSetting" />
  195. </a-tab-pane>
  196. <a-tab-pane key="3" class="tab-pane">
  197. <template #tab>
  198. <MessageOutlined />
  199. <span>{{ t('pages.settings.TGBotSettings') }}</span>
  200. </template>
  201. <TelegramTab :all-setting="allSetting" />
  202. </a-tab-pane>
  203. <a-tab-pane key="4" class="tab-pane">
  204. <template #tab>
  205. <CloudServerOutlined />
  206. <span>{{ t('pages.settings.subSettings') }}</span>
  207. </template>
  208. <SubscriptionGeneralTab :all-setting="allSetting" />
  209. </a-tab-pane>
  210. <a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
  211. <template #tab>
  212. <CodeOutlined />
  213. <span>{{ t('pages.settings.subSettings') }} (Formats)</span>
  214. </template>
  215. <SubscriptionFormatsTab :all-setting="allSetting" />
  216. </a-tab-pane>
  217. </a-tabs>
  218. </a-col>
  219. </a-row>
  220. </template>
  221. </a-spin>
  222. </a-layout-content>
  223. </a-layout>
  224. </a-layout>
  225. </a-config-provider>
  226. </template>
  227. <style scoped>
  228. .settings-page {
  229. --bg-page: #e6e8ec;
  230. --bg-card: #ffffff;
  231. min-height: 100vh;
  232. background: var(--bg-page);
  233. }
  234. .settings-page.is-dark {
  235. --bg-page: #1e1e1e;
  236. --bg-card: #252526;
  237. }
  238. .settings-page.is-dark.is-ultra {
  239. --bg-page: #050505;
  240. --bg-card: #0c0e12;
  241. }
  242. .settings-page :deep(.ant-layout),
  243. .settings-page :deep(.ant-layout-content) {
  244. background: transparent;
  245. }
  246. .content-shell {
  247. background: transparent;
  248. }
  249. .content-area {
  250. padding: 24px;
  251. }
  252. .loading-spacer {
  253. min-height: calc(100vh - 120px);
  254. }
  255. .conf-alert {
  256. margin-bottom: 10px;
  257. }
  258. .header-row {
  259. display: flex;
  260. flex-wrap: wrap;
  261. align-items: center;
  262. }
  263. .header-actions {
  264. padding: 4px;
  265. }
  266. .header-info {
  267. display: flex;
  268. justify-content: flex-end;
  269. }
  270. .tab-pane {
  271. padding-top: 20px;
  272. }
  273. </style>