diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index 61e8b55b..b0c4fe29 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -2,6 +2,12 @@ import { Transition } from 'vue' import { useDisplay } from 'vuetify' import VerticalNav from '@layouts/components/VerticalNav.vue' +import { + readThemeCustomizerSettings, + THEME_CUSTOMIZER_CHANGE_EVENT, + type ThemeCustomizerSettings, +} from '@/composables/useThemeCustomizer' +import { usePWA } from '@/composables/usePWA' export default defineComponent({ setup(props, { slots }) { @@ -11,6 +17,11 @@ export default defineComponent({ const route = useRoute() const { mdAndDown } = useDisplay() + const { appMode } = usePWA() + const themeLayout = ref(readThemeCustomizerSettings().layout) + const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value) + const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed') + const isHorizontalLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'horizontal') // ℹ️ This is alternative to below two commented watcher // We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa. @@ -25,6 +36,10 @@ export default defineComponent({ scrollDistance.value = window.scrollY } + const handleThemeCustomizerChange = (event: Event) => { + themeLayout.value = (event as CustomEvent).detail.layout + } + // 监听弹窗状态变化 const checkDialogState = () => { const wasDialogOpen = isDialogOpen.value @@ -38,6 +53,7 @@ export default defineComponent({ onMounted(() => { window.addEventListener('scroll', handleScroll) + window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange) // 初始检查弹窗状态 checkDialogState() @@ -52,6 +68,7 @@ export default defineComponent({ onBeforeUnmount(() => { window.removeEventListener('scroll', handleScroll) + window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange) dialogObserver?.disconnect() dialogObserver = null }) @@ -127,6 +144,8 @@ export default defineComponent({ 'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid', 'layout-navbar-fixed', mdAndDown.value && 'layout-overlay-nav', + isCollapsedLayout.value && 'layout-vertical-nav-collapsed', + isHorizontalLayout.value && 'layout-horizontal-nav-active', route.meta.layoutWrapperClasses, (scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled', ], @@ -223,6 +242,140 @@ export default defineComponent({ // Adjust right column pl when vertical nav is collapsed &.layout-vertical-nav-collapsed .layout-content-wrapper { padding-inline-start: variables.$layout-vertical-nav-collapsed-width; + + .page-content-container > div:first-child { + inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 1rem); + } + } + + &.layout-vertical-nav-collapsed .layout-navbar { + inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 0.5rem); + } + + &.layout-vertical-nav-collapsed .layout-vertical-nav:not(.overlay-nav) { + .nav-header { + justify-content: center; + padding-inline: 0; + margin-inline: 0; + } + + .app-logo { + justify-content: center; + inline-size: 100%; + transform: none !important; + } + + .app-logo > div { + display: flex; + overflow: hidden; + align-items: center; + justify-content: center; + block-size: 2.75rem; + inline-size: 2.75rem; + } + + .app-logo svg { + block-size: 2.5rem; + inline-size: 2.5rem; + } + + .app-logo h1, + .nav-item-title, + .nav-section-title { + display: none; + } + + .nav-link > a { + justify-content: center; + border-radius: 0.75rem !important; + block-size: 2.75rem; + margin-inline: 0.75rem; + padding-inline: 0; + } + + .nav-item-icon { + margin-inline-end: 0 !important; + } + } + + &.layout-horizontal-nav-active { + .layout-vertical-nav:not(.overlay-nav) { + pointer-events: none; + transform: translateX(-100%); + visibility: hidden; + } + + .layout-content-wrapper { + padding-inline-start: 0; + } + + .layout-navbar { + border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08); + background: rgb(var(--v-theme-background)); + inline-size: 100%; + max-inline-size: none; + padding-inline: 0; + } + + .navbar-content-container { + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + inline-size: 100%; + max-inline-size: variables.$layout-boxed-content-width; + margin-inline: auto; + padding-inline: 1.5rem; + } + + .layout-page-content { + inline-size: 100%; + max-inline-size: variables.$layout-boxed-content-width; + margin-inline: auto; + padding-inline: 1rem; + } + + .page-content-container > div:first-child { + inline-size: 100%; + } + } + + @at-root { + html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .layout-navbar, + .v-theme--transparent .layout-wrapper.layout-horizontal-nav-active .layout-navbar { + backdrop-filter: blur(var(--transparent-blur-heavy, 16px)); + background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2)); + border-block-end-color: rgba(var(--v-theme-on-surface), 0.06); + } + + html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='vertical'] + .layout-wrapper.layout-nav-type-vertical:not(.layout-horizontal-nav-active) + .layout-vertical-nav:not(.overlay-nav), + html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='collapsed'] + .layout-wrapper.layout-nav-type-vertical:not(.layout-horizontal-nav-active) + .layout-vertical-nav:not(.overlay-nav) { + background: #2f3349; + color: #e7e3fc; + + .app-logo h1, + .nav-section-title, + .nav-link > a, + .nav-item-icon { + color: rgba(231, 227, 252, 0.78) !important; + } + + .nav-link > a:hover { + background-color: rgba(231, 227, 252, 0.06); + } + + .nav-link > .router-link-exact-active { + color: #fff !important; + + .nav-item-icon, + .nav-item-title { + color: #fff !important; + } + } + } } // 👉 Content height fixed diff --git a/src/App.vue b/src/App.vue index eee7c131..9f252f1c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,16 +12,19 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue' import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue' +import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer' import { themeManager } from '@/utils/themeManager' import { configureApexChartsTheme } from '@/utils/apexCharts' const LOGIN_WALLPAPER_ROUTE = '/login' // 生效主题 -const { global: globalTheme } = useTheme() +const vuetifyTheme = useTheme() +const { global: globalTheme } = vuetifyTheme let themeValue = localStorage.getItem('theme') || 'auto' const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light' globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue +applyStoredThemeCustomizerAppearance(vuetifyTheme) // 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。 function syncRootLaunchPalette() { @@ -285,6 +288,8 @@ onMounted(async () => { // 初始化主题管理器 - 统一处理主题初始化 await themeManager.setTheme(themeValue) + applyStoredThemeCustomizerAppearance(vuetifyTheme) + updateHtmlThemeAttribute(globalTheme.name.value) // 监听主题变化 watch( diff --git a/src/components/ThemeCustomizer.vue b/src/components/ThemeCustomizer.vue new file mode 100644 index 00000000..a9d67a24 --- /dev/null +++ b/src/components/ThemeCustomizer.vue @@ -0,0 +1,544 @@ + + + + + diff --git a/src/components/dialog/SearchBarDialog.vue b/src/components/dialog/SearchBarDialog.vue index 922de22d..749c7f8b 100644 --- a/src/components/dialog/SearchBarDialog.vue +++ b/src/components/dialog/SearchBarDialog.vue @@ -712,7 +712,7 @@ onMounted(() => { .search-input-wrapper { display: flex; align-items: center; - border: 1.5px solid rgba(var(--v-theme-on-surface), 0.15); + border: 1.5px solid rgba(var(--v-theme-primary), 0.4); border-radius: 28px; background-color: rgba(var(--v-theme-surface-variant), 0.04); block-size: 48px; @@ -723,7 +723,7 @@ onMounted(() => { } .search-input-wrapper:focus-within { - border-color: rgba(var(--v-theme-on-surface), 0.3); + border-color: rgb(var(--v-theme-primary)); box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04); } diff --git a/src/composables/useThemeCustomizer.ts b/src/composables/useThemeCustomizer.ts new file mode 100644 index 00000000..38a83b06 --- /dev/null +++ b/src/composables/useThemeCustomizer.ts @@ -0,0 +1,313 @@ +import { computed, onMounted, onScopeDispose, readonly, ref } from 'vue' +import { useTheme } from 'vuetify' +import { checkPrefersColorSchemeIsDark } from '@/@core/utils' +import { saveLocalTheme } from '@/@core/utils/theme' +import { themeManager } from '@/utils/themeManager' + +export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer' +export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change' + +export const themeCustomizerPrimaryColors = [ + { name: 'Purple', value: '#9155FD' }, + { name: 'Teal', value: '#009688' }, + { name: 'Amber', value: '#FFB400' }, + { name: 'Coral', value: '#FF4C51' }, + { name: 'Sky', value: '#16B1FF' }, +] as const + +export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical' +export type ThemeCustomizerSkin = 'bordered' | 'default' +export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent' + +export interface ThemeCustomizerSettings { + layout: ThemeCustomizerLayout + primaryColor: string + semiDarkMenu: boolean + skin: ThemeCustomizerSkin + theme: ThemeCustomizerTheme +} + +type VuetifyThemeApi = ReturnType + +const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value +const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal'] +const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered'] +const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent'] + +const settingsState = ref(readThemeCustomizerSettings()) +let themeApplyVersion = 0 + +function isBrowser() { + return typeof window !== 'undefined' +} + +function isHexColor(color: unknown): color is string { + return typeof color === 'string' && /^#[\da-f]{6}$/i.test(color) +} + +function readStoredThemePreference(): ThemeCustomizerTheme { + if (!isBrowser()) return 'auto' + + const storedTheme = localStorage.getItem('theme') + + return validThemes.includes(storedTheme as ThemeCustomizerTheme) ? (storedTheme as ThemeCustomizerTheme) : 'auto' +} + +function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings { + return { + layout: 'vertical', + primaryColor: defaultPrimaryColor, + semiDarkMenu: false, + skin: 'default', + theme: readStoredThemePreference(), + } +} + +function normalizeThemeCustomizerSettings(settings: Partial): ThemeCustomizerSettings { + const fallback = getDefaultThemeCustomizerSettings() + + return { + layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout) + ? (settings.layout as ThemeCustomizerLayout) + : fallback.layout, + primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor, + semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu, + skin: validSkins.includes(settings.skin as ThemeCustomizerSkin) ? (settings.skin as ThemeCustomizerSkin) : fallback.skin, + theme: validThemes.includes(settings.theme as ThemeCustomizerTheme) + ? (settings.theme as ThemeCustomizerTheme) + : fallback.theme, + } +} + +/** 从本地存储读取主题定制器设置,异常数据会自动回落到默认值。 */ +export function readThemeCustomizerSettings(): ThemeCustomizerSettings { + const fallback = getDefaultThemeCustomizerSettings() + + if (!isBrowser()) return fallback + + try { + const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY) + const parsed = stored ? JSON.parse(stored) : {} + + return normalizeThemeCustomizerSettings({ + ...fallback, + ...parsed, + theme: readStoredThemePreference(), + }) + } catch (error) { + console.warn('读取主题定制设置失败,已使用默认设置:', error) + + return fallback + } +} + +function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) { + if (!isBrowser()) return + + localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify(settings)) +} + +function dispatchThemeCustomizerChange(settings: ThemeCustomizerSettings) { + if (!isBrowser()) return + + window.dispatchEvent( + new CustomEvent(THEME_CUSTOMIZER_CHANGE_EVENT, { + detail: settings, + }), + ) +} + +function getTextColorForHex(backgroundColor: string) { + const hex = backgroundColor.replace('#', '') + const red = Number.parseInt(hex.slice(0, 2), 16) + const green = Number.parseInt(hex.slice(2, 4), 16) + const blue = Number.parseInt(hex.slice(4, 6), 16) + const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255 + + return luminance > 0.68 ? '#3A3541' : '#FFFFFF' +} + +/** 将主色写入 Vuetify 运行时主题,所有已注册主题会同步更新。 */ +export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyThemeApi) { + if (!isHexColor(color)) return + + const onPrimaryColor = getTextColorForHex(color) + + for (const themeDefinition of Object.values(themeApi.themes.value)) { + themeDefinition.colors.primary = color + themeDefinition.colors['on-primary'] = onPrimaryColor + } + + document.documentElement.style.setProperty('--initial-loader-color', color) + localStorage.setItem('materio-initial-loader-color', color) +} + +/** 布局、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */ +export function applyThemeCustomizerRootSettings(settings: Pick) { + if (!isBrowser()) return + + document.documentElement.setAttribute('data-theme-layout', settings.layout) + document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu)) + document.documentElement.setAttribute('data-theme-skin', settings.skin) + document.body.setAttribute('data-theme-layout', settings.layout) + document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu)) + document.body.setAttribute('data-theme-skin', settings.skin) +} + +function getResolvedThemeName(themePreference: ThemeCustomizerTheme) { + if (themePreference === 'auto') { + return checkPrefersColorSchemeIsDark() ? 'dark' : 'light' + } + + return themePreference +} + +function syncThemeAttribute(themeName: string) { + document.documentElement.setAttribute('data-theme', themeName) + document.body.setAttribute('data-theme', themeName) +} + +async function applyThemePreference(themePreference: ThemeCustomizerTheme, themeApi: VuetifyThemeApi) { + const currentVersion = ++themeApplyVersion + const resolvedTheme = getResolvedThemeName(themePreference) + + themeApi.global.name.value = resolvedTheme + + await themeManager.setTheme(themePreference) + + // auto 模式传给 themeManager 后会写入 data-theme="auto",这里再同步为实际生效主题。 + if (currentVersion === themeApplyVersion) { + syncThemeAttribute(resolvedTheme) + saveLocalTheme(themePreference, themeApi.global) + } +} + +/** 应用已保存的主色、皮肤和布局,供 App 启动阶段在面板挂载前使用。 */ +export function applyStoredThemeCustomizerAppearance(themeApi: VuetifyThemeApi) { + const settings = readThemeCustomizerSettings() + + settingsState.value = settings + applyPrimaryColorToVuetify(settings.primaryColor, themeApi) + applyThemeCustomizerRootSettings(settings) + + return settings +} + +export function persistPartialThemeCustomizerSettings(patch: Partial) { + const nextSettings = normalizeThemeCustomizerSettings({ + ...readThemeCustomizerSettings(), + ...patch, + }) + + settingsState.value = nextSettings + persistThemeCustomizerSettings(nextSettings) + applyThemeCustomizerRootSettings(nextSettings) + dispatchThemeCustomizerChange(nextSettings) + + return nextSettings +} + +export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) { + const defaults = normalizeThemeCustomizerSettings({ + layout: 'vertical', + primaryColor: defaultPrimaryColor, + semiDarkMenu: false, + skin: 'default', + theme: 'auto', + }) + + return ( + settings.layout === defaults.layout && + settings.primaryColor === defaults.primaryColor && + settings.semiDarkMenu === defaults.semiDarkMenu && + settings.skin === defaults.skin && + settings.theme === defaults.theme + ) +} + +/** 提供主题定制器面板使用的响应式状态与操作。 */ +export function useThemeCustomizer() { + const themeApi = useTheme() + const settings = settingsState + + async function updateSettings(patch: Partial) { + const previousTheme = settings.value.theme + const nextSettings = normalizeThemeCustomizerSettings({ + ...settings.value, + ...patch, + }) + + settings.value = nextSettings + persistThemeCustomizerSettings(nextSettings) + applyPrimaryColorToVuetify(nextSettings.primaryColor, themeApi) + applyThemeCustomizerRootSettings(nextSettings) + + if (previousTheme !== nextSettings.theme || themeApi.global.name.value !== getResolvedThemeName(nextSettings.theme)) { + await applyThemePreference(nextSettings.theme, themeApi) + } + + dispatchThemeCustomizerChange(nextSettings) + } + + function setPrimaryColor(color: string) { + return updateSettings({ primaryColor: color }) + } + + function setTheme(theme: ThemeCustomizerTheme) { + return updateSettings({ theme }) + } + + function setSkin(skin: ThemeCustomizerSkin) { + return updateSettings({ skin }) + } + + function setLayout(layout: ThemeCustomizerLayout) { + return updateSettings({ layout }) + } + + function setSemiDarkMenu(semiDarkMenu: boolean) { + return updateSettings({ semiDarkMenu }) + } + + async function resetSettings() { + await updateSettings({ + layout: 'vertical', + primaryColor: defaultPrimaryColor, + semiDarkMenu: false, + skin: 'default', + theme: 'auto', + }) + } + + function handleSystemThemeChange() { + if (settings.value.theme === 'auto') { + updateSettings({ theme: 'auto' }) + } + } + + let mediaQuery: MediaQueryList | null = null + + onMounted(() => { + settings.value = readThemeCustomizerSettings() + applyPrimaryColorToVuetify(settings.value.primaryColor, themeApi) + applyThemeCustomizerRootSettings(settings.value) + + mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)') ?? null + mediaQuery?.addEventListener('change', handleSystemThemeChange) + }) + + onScopeDispose(() => { + mediaQuery?.removeEventListener('change', handleSystemThemeChange) + }) + + return { + isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)), + resetSettings, + setLayout, + setPrimaryColor, + setSemiDarkMenu, + setSkin, + setTheme, + settings: readonly(settings), + } +} diff --git a/src/layouts/components/DefaultLayout.vue b/src/layouts/components/DefaultLayout.vue index 21204cd3..83300a93 100644 --- a/src/layouts/components/DefaultLayout.vue +++ b/src/layouts/components/DefaultLayout.vue @@ -22,12 +22,19 @@ import { usePullDownGesture } from '@/composables/usePullDownGesture' import { usePWA } from '@/composables/usePWA' import OfflinePage from '@/layouts/components/OfflinePage.vue' import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus' +import { + readThemeCustomizerSettings, + THEME_CUSTOMIZER_CHANGE_EVENT, + type ThemeCustomizerSettings, +} from '@/composables/useThemeCustomizer' +import logo from '@images/logo.svg?raw' const display = useDisplay() // PWA模式检测 const { appMode } = usePWA() const { t } = useI18n() const route = useRoute() +const themeLayout = ref(readThemeCustomizerSettings().layout) // 用户 Store const userStore = useUserStore() @@ -60,6 +67,35 @@ const organizeMenus = ref([]) // 系统菜单项 const systemMenus = ref([]) +// 主题定制器的水平布局只在桌面 UI 中启用,App 模式始终保留移动端导航。 +const showHorizontalThemeNav = computed(() => { + return themeLayout.value === 'horizontal' && !appMode.value && !display.mdAndDown.value +}) + +const horizontalNavGroups = computed(() => + [ + { title: t('menu.start'), icon: 'mdi-home-outline', items: startMenus.value }, + { title: t('menu.discovery'), icon: 'mdi-compass-outline', items: discoveryMenus.value }, + { title: t('menu.subscribe'), icon: 'mdi-rss', items: subscribeMenus.value }, + { title: t('menu.organize'), icon: 'mdi-folder-play-outline', items: organizeMenus.value }, + { title: t('menu.system'), icon: 'mdi-cog-outline', items: systemMenus.value }, + ].filter(group => group.items.length > 0), +) + +const navbarExtraHeight = computed(() => { + const dynamicTabHeight = showDynamicHeaderTab.value ? 2.5 : 0 + const horizontalNavHeight = showHorizontalThemeNav.value ? 3.25 : 0 + + return `${dynamicTabHeight + horizontalNavHeight}rem` +}) + +const mainContentPaddingTop = computed(() => { + const dynamicTabPadding = showDynamicHeaderTab.value ? 3 : 0 + const horizontalNavPadding = showHorizontalThemeNav.value ? 3.5 : 0 + + return `${dynamicTabPadding + horizontalNavPadding}rem` +}) + // 插件快速访问相关状态 const showPluginQuickAccess = ref(false) @@ -68,26 +104,35 @@ const { setAppOffline, isOffline } = useGlobalOfflineStatus() // 动态标签页相关 // 定义动态标签页类型 +interface DynamicHeaderTabButton { + icon: string + color?: string | ComputedRef + variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain' + size?: string + class?: string + action?: () => void + show?: boolean | ComputedRef + loading?: boolean | ComputedRef + dataAttr?: string +} + +interface DynamicHeaderTabItem { + title: string + icon?: string + tab: string +} + interface DynamicHeaderTab { - items: Array<{ title: string; icon: string; tab: string }> + items: DynamicHeaderTabItem[] modelValue: string - appendButtons?: Array<{ - icon: string - color?: string | ComputedRef - variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain' - size?: string - class?: string - action?: () => void - show?: boolean | ComputedRef - loading?: boolean | ComputedRef - dataAttr?: string - }> + appendButtons?: DynamicHeaderTabButton[] routePath?: string // 用于标识哪个路由注册的 onUpdateModelValue?: (value: string) => void // 用于通知值更新 } // 提供动态标签页注册和获取的方法 const dynamicHeaderTab = ref(null) +const openHorizontalNavGroup = ref(null) // 提供一个方法让其他组件注册动态标签页 const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => { @@ -138,13 +183,22 @@ watch( { immediate: false }, ) -// 显示动态标签页 -const showDynamicHeaderTab = computed(() => { +// 当前路由是否注册了动态标签页。 +const hasDynamicHeaderTab = computed(() => { return ( dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path ) }) +// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。 +const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value) + +const visibleHorizontalHeaderButtons = computed(() => { + if (!showHorizontalThemeNav.value || !hasDynamicHeaderTab.value) return [] + + return (dynamicHeaderTab.value?.appendButtons ?? []).filter(button => resolveMaybeRefValue(button.show, true) !== false) +}) + // 在组件销毁时清理 onUnmounted(() => { dynamicHeaderTab.value = null @@ -210,6 +264,52 @@ function goBack() { history.back() } +function handleThemeCustomizerChange(event: Event) { + themeLayout.value = (event as CustomEvent).detail.layout +} + +function isHorizontalNavActive(item: NavMenu) { + if (typeof item.to !== 'string') return false + + const targetPath = item.to.replace(/\/$/, '') + const currentPath = route.path.replace(/\/$/, '') + + return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`) +} + +function isHorizontalNavGroupActive(group: { items: NavMenu[] }) { + return group.items.some(isHorizontalNavActive) +} + +function hasHorizontalDynamicTabs(item: NavMenu) { + return showHorizontalThemeNav.value && hasDynamicHeaderTab.value && isHorizontalNavActive(item) +} + +function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) { + return dynamicHeaderTab.value?.modelValue === tab.tab +} + +function handleHorizontalDynamicTabSelect(tab: DynamicHeaderTabItem) { + handleTabChange(tab.tab) + openHorizontalNavGroup.value = null +} + +function closeHorizontalNavGroup() { + openHorizontalNavGroup.value = null +} + +function resolveMaybeRefValue(value: T | ComputedRef | undefined, fallback: T): T { + return isRef(value) ? value.value : value ?? fallback +} + +function resolveHeaderButtonColor(button: DynamicHeaderTabButton) { + return resolveMaybeRefValue(button.color, 'gray') +} + +function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) { + return resolveMaybeRefValue(button.loading, false) +} + // 处理未读消息事件 function handleUnreadMessage(count: number) { if (superUser.value && count > 0) { @@ -278,9 +378,12 @@ onMounted(async () => { navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage) } + window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange) + // 组件卸载时清理监听 onBeforeUnmount(() => { unsubscribe() + window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange) if ('serviceWorker' in navigator) { navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage) } @@ -316,10 +419,17 @@ onMounted(async () => { /> - + {{ t('common.theme') }} - {{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }} + {{ currentThemeSummary }} - + - {{ theme.title }} - @@ -707,6 +777,7 @@ onUnmounted(() => { +