diff --git a/frontend/settings.html b/frontend/settings.html
new file mode 100644
index 00000000..455deb22
--- /dev/null
+++ b/frontend/settings.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ 3x-ui · Settings
+
+
+
+
+
+
+
diff --git a/frontend/src/models/setting.js b/frontend/src/models/setting.js
index fdca27ec..7efc9e7d 100644
--- a/frontend/src/models/setting.js
+++ b/frontend/src/models/setting.js
@@ -1,4 +1,9 @@
-import { ObjectUtil } from '../utils/legacy.js';
+// Mirrors web/assets/js/model/setting.js — every field on this class is
+// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
+// so adding a field here without a matching Go-side change will silently
+// drop it on save. Defaults match the legacy panel.
+
+import { ObjectUtil } from '@/utils';
export class AllSetting {
diff --git a/frontend/src/pages/settings/SettingsPage.vue b/frontend/src/pages/settings/SettingsPage.vue
new file mode 100644
index 00000000..c376d2ff
--- /dev/null
+++ b/frontend/src/pages/settings/SettingsPage.vue
@@ -0,0 +1,297 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Security warnings
+
+ Your panel may be exposed:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Panel
+
+
+
+
+
+
+ Security
+
+
+
+
+
+
+ Telegram
+
+
+
+
+
+
+ Subscription
+
+
+
+
+
+
+ Subscription (Formats)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/settings/useAllSetting.js b/frontend/src/pages/settings/useAllSetting.js
new file mode 100644
index 00000000..8b09d00d
--- /dev/null
+++ b/frontend/src/pages/settings/useAllSetting.js
@@ -0,0 +1,80 @@
+// Centralizes the AllSetting fetch/save lifecycle the legacy panel
+// scattered across data() + methods + a busy-loop dirty checker.
+//
+// The dirty flag is recomputed once per second (matching the legacy
+// `while (true) sleep(1000)` poll) — we don't deep-watch because the
+// settings tree has many nested fields and a poll is cheap enough.
+
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { HttpUtil } from '@/utils';
+import { AllSetting } from '@/models/setting.js';
+
+const DIRTY_POLL_MS = 1000;
+
+export function useAllSetting() {
+ const fetched = ref(false);
+ const spinning = ref(false);
+ const saveDisabled = ref(true);
+
+ // Two reactive snapshots: the last server-side state and the one the
+ // user is editing. `equals` compares enumerable props field-by-field.
+ const oldAllSetting = reactive(new AllSetting());
+ const allSetting = reactive(new AllSetting());
+
+ function applyServerState(obj) {
+ const fresh = new AllSetting(obj);
+ Object.assign(oldAllSetting, fresh);
+ Object.assign(allSetting, fresh);
+ saveDisabled.value = true;
+ }
+
+ async function fetchAll() {
+ const msg = await HttpUtil.post('/panel/setting/all');
+ if (msg?.success) {
+ fetched.value = true;
+ applyServerState(msg.obj);
+ }
+ }
+
+ async function saveAll() {
+ spinning.value = true;
+ try {
+ const msg = await HttpUtil.post('/panel/setting/update', allSetting);
+ if (msg?.success) await fetchAll();
+ } finally {
+ spinning.value = false;
+ }
+ }
+
+ let timer = null;
+ function startDirtyPoll() {
+ if (timer != null) return;
+ timer = setInterval(() => {
+ // ObjectUtil.equals walks own enumerable props; reactive proxies
+ // expose them transparently so this works without cloning.
+ saveDisabled.value = oldAllSetting.equals(allSetting);
+ }, DIRTY_POLL_MS);
+ }
+ function stopDirtyPoll() {
+ if (timer != null) {
+ clearInterval(timer);
+ timer = null;
+ }
+ }
+
+ onMounted(() => {
+ fetchAll();
+ startDirtyPoll();
+ });
+ onUnmounted(stopDirtyPoll);
+
+ return {
+ fetched,
+ spinning,
+ saveDisabled,
+ oldAllSetting,
+ allSetting,
+ fetchAll,
+ saveAll,
+ };
+}
diff --git a/frontend/src/settings.js b/frontend/src/settings.js
new file mode 100644
index 00000000..83f9ec32
--- /dev/null
+++ b/frontend/src/settings.js
@@ -0,0 +1,18 @@
+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';
+// Importing useTheme triggers the boot side-effect that applies the
+// stored theme to / before Vue mounts.
+import '@/composables/useTheme.js';
+import SettingsPage from '@/pages/settings/SettingsPage.vue';
+
+setupAxios();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+ message.config({ getContainer: () => messageContainer });
+}
+
+createApp(SettingsPage).use(Antd).mount('#app');
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 6dd77531..87aa8416 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -45,6 +45,7 @@ export default defineConfig({
input: {
index: path.resolve(__dirname, 'index.html'),
login: path.resolve(__dirname, 'login.html'),
+ settings: path.resolve(__dirname, 'settings.html'),
},
},
},