feat(frontend): Phase 5a — theme system + Vite 8 + vue-i18n 11

Bumps Vite to 8.0.11 (npm install picked up 6.4.2 from the stale
lockfile; clean install resolves the new constraint). Bumps vue-i18n
to 11.1.4 since v10 was just EOL'd.

Migrates aThemeSwitch.html — the two-flavor theme picker + global
themeSwitcher object — into:

- composables/useTheme.js: single reactive `theme` state with
  toggleTheme / toggleUltra. Boot side-effect applies the stored theme
  to <body>/<html> before Vue renders; watchEffect persists changes
  back to localStorage.
- components/ThemeSwitch.vue: full menu version for the main panel.
- components/ThemeSwitchLogin.vue: login-popover version.

AD-Vue 1 → 4 changes hit on this component:
- <a-icon type="bulb" :theme="filled|outlined"> dropped — replaced by
  explicit BulbFilled / BulbOutlined imports from
  @ant-design/icons-vue, swapped via <component :is="BulbIcon">
- Vue.component('a-theme-switch', { ... }) global registration → SFC
  + per-page import
- this.$message.config(...) (Vue 2 instance method) → message.config(...)
  imported from ant-design-vue, called once in login.js at boot

Login page now surfaces a settings button → popover → theme picker.

Known gap: web/assets/css/custom.min.css isn't yet imported into the
new bundle, so toggling dark mode currently only re-themes AD-Vue's
own components, not the panel chrome. The body class is still toggled
so behavior is correct; visual fidelity returns when custom.css is
ported or directly imported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei
2026-05-08 11:11:06 +02:00
parent 772e778aa0
commit 138696cf36
9 changed files with 850 additions and 747 deletions
+46
View File
@@ -0,0 +1,46 @@
<script setup>
import { computed } from 'vue';
import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
function onDarkChange() {
pauseAnimationsUntilLeave('change-theme');
toggleTheme();
}
function onUltraClick() {
pauseAnimationsUntilLeave('change-theme-ultra');
toggleUltra();
}
</script>
<template>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
<a-sub-menu>
<template #title>
<span>
<component :is="BulbIcon" />
<span class="theme-label">Theme</span>
</span>
</template>
<a-menu-item id="change-theme" class="ant-menu-theme-switch">
<span>Dark</span>
<a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
</a-menu-item>
<a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
<span>Ultra dark</span>
<a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
</a-menu-item>
</a-sub-menu>
</a-menu>
</template>
<style scoped>
.theme-label {
margin-left: 8px;
}
</style>
@@ -0,0 +1,25 @@
<script setup>
import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
function onDarkChange() {
pauseAnimationsUntilLeave('change-theme');
toggleTheme();
}
function onUltraClick() {
toggleUltra();
}
</script>
<template>
<a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
<a-space direction="horizontal" size="small">
<a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
<span>Dark</span>
</a-space>
<a-space v-if="theme.isDark" direction="horizontal" size="small">
<a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
<span>Ultra dark</span>
</a-space>
</a-space>
</template>
+67
View File
@@ -0,0 +1,67 @@
import { reactive, computed, watchEffect } from 'vue';
// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
// from any component to read/toggle. Boot side-effects (apply current
// theme to <body>/<html>) run once at module load so the page is in the
// right theme before Vue mounts.
const STORAGE_DARK = 'dark-mode';
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
function readBool(key, fallback) {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return raw === 'true';
}
const isDark = readBool(STORAGE_DARK, true);
const isUltra = readBool(STORAGE_ULTRA, false);
export const theme = reactive({
isDark,
isUltra,
});
export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
export function toggleTheme() {
theme.isDark = !theme.isDark;
}
export function toggleUltra() {
theme.isUltra = !theme.isUltra;
}
// Briefly disable theme transition animations while a toggle is in
// flight, then re-enable on mouseleave. Mirrors the legacy panel's
// behavior of preventing flicker when hovering the theme menu.
export function pauseAnimationsUntilLeave(elementId) {
document.documentElement.setAttribute('data-theme-animations', 'off');
const el = document.getElementById(elementId);
if (!el) return;
const restore = () => {
document.documentElement.removeAttribute('data-theme-animations');
el.removeEventListener('mouseleave', restore);
el.removeEventListener('touchend', restore);
};
el.addEventListener('mouseleave', restore);
el.addEventListener('touchend', restore);
}
// Apply theme to DOM and persist whenever it changes.
watchEffect(() => {
document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
localStorage.setItem(STORAGE_DARK, String(theme.isDark));
if (theme.isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
// Keep the global #message container's class in sync so AD-Vue toasts
// pick up the right styling.
const msg = document.getElementById('message');
if (msg) msg.className = theme.isDark ? 'dark' : 'light';
});
+11 -1
View File
@@ -1,10 +1,20 @@
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
// Toasts attach to a #message div the page provides — keeps theme
// styling in sync with the rest of the panel.
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(LoginPage).use(Antd).mount('#app');
+20 -1
View File
@@ -1,8 +1,10 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { UserOutlined, LockOutlined, KeyOutlined } from '@ant-design/icons-vue';
import { UserOutlined, LockOutlined, KeyOutlined, SettingOutlined } from '@ant-design/icons-vue';
import { HttpUtil } from '@/utils';
import { currentTheme } from '@/composables/useTheme.js';
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
// Phase 4 ships this page in English only. Translations come back in
// Phase 7 (vue-i18n) once we decide how the new build pipeline reads
@@ -69,6 +71,17 @@ async function login() {
</div>
<div v-else>
<div class="login-settings">
<a-popover :overlay-class-name="currentTheme" title="Settings" placement="bottomRight" trigger="click">
<template #content>
<ThemeSwitchLogin />
</template>
<a-button shape="circle">
<template #icon><SettingOutlined /></template>
</a-button>
</a-popover>
</div>
<a-row justify="center">
<a-col :span="24">
<h2 class="login-title">Welcome to 3x-ui</h2>
@@ -134,6 +147,12 @@ async function login() {
background: #f0f2f5;
}
.login-settings {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.login-content {
position: relative;
}