feat: add theme customizer component and functionality

- Introduced a new ThemeCustomizer component for real-time theme customization.
- Updated UserProfile.vue to integrate the ThemeCustomizer and manage theme settings.
- Enhanced theme management with new settings for layout, primary color, and skin.
- Added support for semi-dark menu and responsive design adjustments.
- Implemented local storage persistence for theme settings.
- Updated localization files to include new theme customizer strings in English, Simplified Chinese, and Traditional Chinese.
- Modified styles to support new bordered skin and theme customizer layout.
- Refactored existing components (HeaderTab, SearchBar) to accommodate new theme features.
This commit is contained in:
jxxghp
2026-06-02 16:16:20 +08:00
parent 285ddab45a
commit 50b0148ed6
13 changed files with 1484 additions and 46 deletions

View File

@@ -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<ThemeCustomizerSettings>).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

View File

@@ -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(

View File

@@ -0,0 +1,544 @@
<script setup lang="ts">
import {
themeCustomizerPrimaryColors,
useThemeCustomizer,
type ThemeCustomizerLayout,
type ThemeCustomizerSkin,
type ThemeCustomizerTheme,
} from '@/composables/useThemeCustomizer'
import { usePWA } from '@/composables/usePWA'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
const props = withDefaults(
defineProps<{
modelValue?: boolean
}>(),
{
modelValue: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const customColorInput = ref<HTMLInputElement | null>(null)
const { isCustomized, resetSettings, setLayout, setPrimaryColor, setSemiDarkMenu, setSkin, setTheme, settings } =
useThemeCustomizer()
const { appMode } = usePWA()
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const drawer = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [
{ title: t('theme.light'), value: 'light', icon: 'mdi-white-balance-sunny' },
{ title: t('theme.dark'), value: 'dark', icon: 'mdi-weather-night' },
{ title: t('theme.auto'), value: 'auto', icon: 'mdi-monitor' },
{ title: t('theme.purple'), value: 'purple', icon: 'mdi-theme-light-dark' },
{ title: t('theme.transparent'), value: 'transparent', icon: 'mdi-blur' },
])
const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }>>(() => [
{ title: t('theme.customizer.skinDefault'), value: 'default' },
{ title: t('theme.customizer.skinBordered'), value: 'bordered' },
])
const layoutOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerLayout }>>(() => [
{ title: t('theme.customizer.layoutVertical'), value: 'vertical', icon: 'mdi-dock-left' },
{ title: t('theme.customizer.layoutCollapsed'), value: 'collapsed', icon: 'mdi-dock-window' },
{ title: t('theme.customizer.layoutHorizontal'), value: 'horizontal', icon: 'mdi-dock-top' },
])
const showSemiDarkMenuOption = computed(() => {
return (
!globalTheme.current.value.dark && (settings.value.layout === 'vertical' || settings.value.layout === 'collapsed')
)
})
function openColorPicker() {
customColorInput.value?.click()
}
function handleCustomColorInput(event: Event) {
const color = (event.target as HTMLInputElement).value
setPrimaryColor(color)
}
function handleLayoutChange(layout: ThemeCustomizerLayout) {
// App 模式固定使用移动端导航,避免切换桌面布局后破坏底部导航体验。
if (appMode.value) return
setLayout(layout)
}
</script>
<template>
<VNavigationDrawer
v-model="drawer"
class="theme-customizer-drawer"
location="right"
temporary
width="420"
:scrim="false"
>
<div class="theme-customizer-header">
<div>
<h2 class="theme-customizer-title">{{ t('theme.customizer.title') }}</h2>
<p class="theme-customizer-subtitle">{{ t('theme.customizer.subtitle') }}</p>
</div>
<div class="theme-customizer-header-actions">
<VBadge color="error" dot :model-value="isCustomized" location="top end" offset-x="2" offset-y="2">
<IconBtn :aria-label="t('theme.customizer.reset')" @click="resetSettings">
<VIcon icon="mdi-refresh" />
</IconBtn>
</VBadge>
<IconBtn :aria-label="t('common.close')" @click="drawer = false">
<VIcon icon="mdi-close" />
</IconBtn>
</div>
</div>
<VDivider />
<PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
<section class="theme-customizer-section">
<span class="theme-customizer-chip">{{ t('theme.customizer.theming') }}</span>
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
<div class="theme-customizer-color-grid">
<button
v-for="color in themeCustomizerPrimaryColors"
:key="color.value"
type="button"
class="theme-customizer-color-option"
:class="{ 'is-active': settings.primaryColor === color.value }"
:aria-label="t('theme.customizer.usePrimaryColor', { color: color.name })"
@click="setPrimaryColor(color.value)"
>
<span class="theme-customizer-color-swatch" :style="{ backgroundColor: color.value }" />
</button>
<button
type="button"
class="theme-customizer-color-option theme-customizer-color-option--picker"
:class="{
'is-active': !themeCustomizerPrimaryColors.some(color => color.value === settings.primaryColor),
}"
:aria-label="t('theme.customizer.chooseCustomColor')"
@click="openColorPicker"
>
<VIcon icon="mdi-palette-outline" size="30" />
<input
ref="customColorInput"
class="theme-customizer-native-color"
type="color"
:value="settings.primaryColor"
@input="handleCustomColorInput"
/>
</button>
</div>
<h3 class="theme-customizer-section-title">{{ t('common.theme') }}</h3>
<div class="theme-customizer-option-grid theme-customizer-option-grid--theme">
<button
v-for="theme in themeOptions"
:key="theme.value"
type="button"
class="theme-customizer-card-option"
:class="{ 'is-active': settings.theme === theme.value }"
@click="setTheme(theme.value)"
>
<VIcon :icon="theme.icon" size="36" />
<span>{{ theme.title }}</span>
</button>
</div>
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.skins') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--skins">
<button
v-for="skin in skinOptions"
:key="skin.value"
type="button"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.skin === skin.value }"
@click="setSkin(skin.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ skin.title }}</span>
</button>
</div>
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
<VSwitch
:model-value="settings.semiDarkMenu"
color="primary"
inset
hide-details
@update:model-value="setSemiDarkMenu(Boolean($event))"
/>
</div>
</section>
<VDivider />
<section class="theme-customizer-section">
<span class="theme-customizer-chip">{{ t('theme.customizer.layout') }}</span>
<h3 class="theme-customizer-section-title"></h3>
<div class="theme-customizer-preview-grid">
<button
v-for="layout in layoutOptions"
:key="layout.value"
type="button"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.layout === layout.value, 'is-disabled': appMode }"
@click="handleLayoutChange(layout.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${layout.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ layout.title }}</span>
</button>
</div>
</section>
</PerfectScrollbar>
</VNavigationDrawer>
</template>
<style lang="scss">
.theme-customizer-drawer {
z-index: 12000 !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
box-shadow: -2px 0 6px rgba(0, 0, 0, 10%) !important;
.v-navigation-drawer__content {
display: flex;
overflow: hidden;
flex-direction: column;
}
}
.theme-customizer-drawer .v-theme--transparent,
.v-theme--transparent .theme-customizer-drawer,
html[data-theme='transparent'] .theme-customizer-drawer {
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5)) !important;
box-shadow: -2px 0 6px rgba(0, 0, 0, 16%) !important;
}
.theme-customizer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-block: 28px 26px;
padding-inline: 32px 24px;
}
.theme-customizer-title {
margin: 0;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.45rem;
font-weight: 600;
line-height: 1.2;
}
.theme-customizer-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 1rem;
line-height: 1.35;
margin-block: 8px 0;
margin-inline: 0;
}
.theme-customizer-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.theme-customizer-body {
flex: 1 1 auto;
min-block-size: 0;
}
.theme-customizer-section {
padding-block: 28px;
padding-inline: 32px;
}
.theme-customizer-chip {
display: inline-flex;
align-items: center;
border-radius: 6px;
background: rgba(var(--v-theme-primary), 0.14);
color: rgb(var(--v-theme-primary));
font-size: 1.25rem;
font-weight: 600;
line-height: 1.1;
padding-block: 6px;
padding-inline: 14px;
}
.theme-customizer-section-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1rem;
font-weight: 600;
line-height: 1.25;
margin-block: 28px 16px;
}
.theme-customizer-section-note {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
line-height: 1.45;
margin-block: -6px 16px;
}
.theme-customizer-color-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.theme-customizer-color-option {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
aspect-ratio: 1;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease;
&.is-active {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
}
}
.theme-customizer-color-swatch {
display: block;
border-radius: 9px;
block-size: 68%;
inline-size: 68%;
}
.theme-customizer-color-option--picker {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.theme-customizer-native-color {
position: absolute;
block-size: 1px;
inline-size: 1px;
inset-block: 50% auto;
inset-inline: 50% auto;
opacity: 0;
pointer-events: none;
}
.theme-customizer-option-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.theme-customizer-option-grid--theme {
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
}
.theme-customizer-card-option,
.theme-customizer-preview-option {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
font-size: 1rem;
gap: 10px;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
&.is-active {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.08);
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
}
.theme-customizer-card-option {
justify-content: center;
padding: 16px;
min-block-size: 112px;
}
.theme-customizer-preview-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.theme-customizer-preview-grid--skins {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.theme-customizer-preview-option {
align-items: flex-start;
padding: 0;
border: 0;
background: transparent;
box-shadow: none !important;
&.is-active {
background: transparent;
box-shadow: none !important;
.theme-customizer-mini-layout {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.04);
}
}
> span:last-child {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
padding-inline-start: 2px;
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.52;
}
}
.theme-customizer-semi-dark {
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.1rem;
font-weight: 600;
margin-block-start: 28px;
margin-inline: -32px;
padding-inline: 32px;
}
.theme-customizer-mini-layout {
display: grid;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
block-size: 74px;
grid-template-columns: 34% 1fr;
inline-size: 100%;
min-inline-size: 92px;
}
.theme-customizer-mini-layout--collapsed {
grid-template-columns: 18% 1fr;
}
.theme-customizer-mini-layout--horizontal {
grid-template-columns: 1fr;
grid-template-rows: 24% 1fr;
}
.mini-sidebar,
.mini-content {
display: flex;
flex-direction: column;
padding: 10px;
gap: 8px;
}
.mini-sidebar {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.mini-sidebar i,
.mini-content i {
display: block;
border-radius: 4px;
background: rgba(var(--v-theme-on-surface), 0.12);
block-size: 6px;
}
.mini-content i {
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 18px;
}
.theme-customizer-mini-layout--bordered {
.mini-content i,
.mini-sidebar i {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
background: transparent;
}
}
.theme-customizer-mini-layout--horizontal {
.mini-sidebar {
flex-direction: row;
align-items: center;
}
}
@media (width <= 600px) {
.theme-customizer-drawer {
inline-size: min(100vw, 420px) !important;
}
.theme-customizer-header,
.theme-customizer-section {
padding-inline: 22px;
}
.theme-customizer-color-grid,
.theme-customizer-preview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -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);
}

View File

@@ -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<typeof useTheme>
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<ThemeCustomizerSettings>(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>): 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<ThemeCustomizerSettings>(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<ThemeCustomizerSettings, 'layout' | 'semiDarkMenu' | 'skin'>) {
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<ThemeCustomizerSettings>) {
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<ThemeCustomizerSettings>) {
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),
}
}

View File

@@ -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<NavMenu[]>([])
// 系统菜单项
const systemMenus = ref<NavMenu[]>([])
// 主题定制器的水平布局只在桌面 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<string>
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
size?: string
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
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<string>
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
size?: string
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
}>
appendButtons?: DynamicHeaderTabButton[]
routePath?: string // 用于标识哪个路由注册的
onUpdateModelValue?: (value: string) => void // 用于通知值更新
}
// 提供动态标签页注册和获取的方法
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
const openHorizontalNavGroup = ref<string | null>(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<ThemeCustomizerSettings>).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<T>(value: T | ComputedRef<T> | 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 () => {
/>
</div>
</div>
<VerticalNavLayout :style="{ '--navbar-tab-height': showDynamicHeaderTab ? '2.5rem' : '0px' }">
<VerticalNavLayout :style="{ '--navbar-tab-height': navbarExtraHeight }">
<!-- 👉 Navbar -->
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-14 align-center mx-1">
<div
class="theme-navbar-row d-flex h-14 align-center mx-1"
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
>
<RouterLink v-if="showHorizontalThemeNav" to="/dashboard" class="theme-horizontal-logo">
<span class="theme-horizontal-logo__mark" v-html="logo" />
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
</RouterLink>
<!-- 👉 Vertical Nav Toggle -->
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
<VIcon icon="mdi-menu" />
@@ -339,6 +449,94 @@ onMounted(async () => {
<!-- 👉 UserProfile -->
<UserProfile />
</div>
<div v-if="showHorizontalThemeNav" class="theme-horizontal-nav">
<VMenu
v-for="group in horizontalNavGroups"
:key="group.title"
:model-value="openHorizontalNavGroup === group.title"
location="bottom start"
offset="8"
:close-on-content-click="false"
@update:model-value="openHorizontalNavGroup = $event ? group.title : null"
>
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
:prepend-icon="group.icon"
append-icon="mdi-chevron-down"
:variant="isHorizontalNavGroupActive(group) ? 'tonal' : 'text'"
:color="isHorizontalNavGroupActive(group) ? 'primary' : 'default'"
rounded="pill"
class="theme-horizontal-nav__item"
>
{{ group.title }}
</VBtn>
</template>
<VList class="theme-horizontal-nav__menu" min-width="13rem" density="comfortable">
<template v-for="item in group.items" :key="`${group.title}-${item.title}-${item.to}`">
<VMenu
v-if="hasHorizontalDynamicTabs(item)"
location="end top"
offset="8"
:close-on-content-click="true"
>
<template #activator="{ props: subMenuProps }">
<VListItem v-bind="subMenuProps" :active="isHorizontalNavActive(item)">
<template #prepend>
<VIcon :icon="String(item.icon || '')" />
</template>
<VListItemTitle>{{ item.full_title || item.title }}</VListItemTitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
</template>
<VList class="theme-horizontal-nav__submenu" min-width="12rem" density="comfortable">
<VListItem
v-for="tab in dynamicHeaderTab!.items"
:key="`${item.to}-${tab.tab}`"
:active="isHorizontalDynamicTabActive(tab)"
@click="handleHorizontalDynamicTabSelect(tab)"
>
<template v-if="tab.icon" #prepend>
<VIcon :icon="tab.icon" />
</template>
<VListItemTitle>{{ tab.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<VListItem
v-else
:to="item.to || undefined"
:active="isHorizontalNavActive(item)"
@click="closeHorizontalNavGroup"
>
<template #prepend>
<VIcon :icon="String(item.icon || '')" />
</template>
<VListItemTitle>{{ item.full_title || item.title }}</VListItemTitle>
</VListItem>
</template>
</VList>
</VMenu>
<div v-if="visibleHorizontalHeaderButtons.length" class="theme-horizontal-nav__actions">
<VBtn
v-for="button in visibleHorizontalHeaderButtons"
:key="button.icon"
:icon="button.icon"
:variant="button.variant || 'text'"
:color="resolveHeaderButtonColor(button)"
:size="button.size || 'default'"
:class="button.class || 'settings-icon-button'"
:loading="resolveHeaderButtonLoading(button)"
:data-menu-activator="button.dataAttr"
@click="button.action"
/>
</div>
</div>
</template>
<template #vertical-nav-content>
@@ -412,7 +610,7 @@ onMounted(async () => {
:style="{
transform: contentTransform,
transition: contentTransition,
paddingTop: showDynamicHeaderTab ? '3rem' : '0px',
paddingTop: mainContentPaddingTop,
}"
>
<slot />
@@ -443,6 +641,74 @@ onMounted(async () => {
will-change: transform;
}
.theme-navbar-row--horizontal {
gap: 1rem;
margin-inline: 0 !important;
}
.theme-horizontal-logo {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
column-gap: 0.75rem;
font-weight: 700;
letter-spacing: 0;
line-height: 1;
text-decoration: none;
}
.theme-horizontal-logo__mark {
display: inline-flex;
align-items: center;
justify-content: center;
block-size: 2rem;
inline-size: 2rem;
}
.theme-horizontal-logo__mark :deep(svg) {
display: block;
block-size: 1.8rem;
inline-size: 1.8rem;
}
.theme-horizontal-logo__text {
font-size: 1.125rem;
white-space: nowrap;
}
.theme-horizontal-nav {
display: flex;
overflow-x: auto;
align-items: center;
block-size: 3.25rem;
gap: 0.25rem;
padding-block: 0.25rem 0.5rem;
padding-inline: 0.5rem;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.theme-horizontal-nav__item {
flex: 0 0 auto;
letter-spacing: 0;
}
.theme-horizontal-nav__menu,
.theme-horizontal-nav__submenu {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.theme-horizontal-nav__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
margin-inline-start: auto;
}
.pull-indicator {
position: fixed;
display: flex;

View File

@@ -7,7 +7,7 @@ const props = defineProps({
default: '',
},
items: {
type: Array as PropType<{ title: string; icon: string; tab: string }[]>,
type: Array as PropType<{ title: string; icon?: string; tab: string }[]>,
default: () => [],
},
// 新增是否启用PWA状态恢复

View File

@@ -43,25 +43,28 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
.search-trigger {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.12);
border: 1.5px solid rgba(var(--v-theme-primary), 0.6);
border-radius: 22px;
block-size: 36px;
cursor: pointer;
gap: 8px;
padding-inline: 12px;
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
user-select: none;
}
.search-trigger:hover {
border-color: rgba(var(--v-theme-on-surface), 0.22);
border-color: rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-on-surface), 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);
}
.search-trigger-icon {
color: rgba(var(--v-theme-on-surface), 0.4);
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.4);
}
.search-trigger-text {

View File

@@ -16,11 +16,20 @@ import { useConfirm } from '@/composables/useConfirm'
import { themeManager } from '@/utils/themeManager'
import { usePWA, type UIMode } from '@/composables/usePWA'
import { applyStoredTransparencySettings } from '@/composables/useTransparencySettings'
import {
persistPartialThemeCustomizerSettings,
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const TransparencySettingsDialog = defineAsyncComponent(() => import('@/components/dialog/TransparencySettingsDialog.vue'))
const TransparencySettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
)
const ThemeCustomizer = defineAsyncComponent(() => import('@/components/ThemeCustomizer.vue'))
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
// 认证 Store
@@ -32,7 +41,7 @@ const globalSettingsStore = useGlobalSettingsStore()
// 国际化
const { t } = useI18n()
// PWA
const { uiMode, setUIMode } = usePWA()
const { appMode, uiMode, setUIMode } = usePWA()
// 提示框
const $toast = useToast()
@@ -43,6 +52,9 @@ const showUIModeMenu = ref(false)
// 主题菜单是否显示
const showThemeMenu = ref(false)
// 主题定制器面板是否显示
const showThemeCustomizer = ref(false)
// 语言菜单是否显示
const showLanguageMenu = ref(false)
@@ -264,6 +276,7 @@ const getUIModeIcon = computed(() => {
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
const currentThemeName = ref(savedTheme.value)
const themeCustomizerSettings = ref(readThemeCustomizerSettings())
const themes: ThemeSwitcherTheme[] = [
{
@@ -293,6 +306,25 @@ const themes: ThemeSwitcherTheme[] = [
},
]
function getThemeLayoutTitle(layout: ThemeCustomizerSettings['layout']) {
switch (layout) {
case 'collapsed':
return t('theme.customizer.layoutCollapsed')
case 'horizontal':
return t('theme.customizer.layoutHorizontal')
case 'vertical':
default:
return t('theme.customizer.layoutVertical')
}
}
const currentThemeSummary = computed(() => {
const themeTitle = themes.find(theme => theme.name === currentThemeName.value)?.title || t('theme.auto')
const layoutTitle = getThemeLayoutTitle(themeCustomizerSettings.value.layout)
return `${themeTitle} · ${layoutTitle}`
})
// Ace 跟随 Vuetify 当前生效主题,避免 auto 模式或弹窗打开后切主题时颜色不同步。
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
@@ -328,6 +360,7 @@ async function changeTheme(theme: string) {
// 保存主题到服务端
try {
persistPartialThemeCustomizerSettings({ theme: theme as ThemeCustomizerSettings['theme'] })
api.post('/user/config/Layout', {
theme,
})
@@ -336,6 +369,18 @@ async function changeTheme(theme: string) {
}
}
function handleThemeCustomizerSettingsChange(event: Event) {
const nextSettings = (event as CustomEvent<ThemeCustomizerSettings>).detail
const nextTheme = nextSettings.theme
themeCustomizerSettings.value = nextSettings
if (currentThemeName.value === nextTheme) return
currentThemeName.value = nextTheme
savedTheme.value = nextTheme
}
// 获取自定义 CSS
async function getCustomCSS() {
try {
@@ -379,6 +424,14 @@ function showTransparencySettingsDialog() {
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
}
/** 从用户菜单打开主题定制器App 模式下入口不显示,这里仍保留保护。 */
function showThemeCustomizerDrawer() {
if (appMode.value) return
showThemeMenu.value = false
showThemeCustomizer.value = true
}
/** 保存自定义 CSS。 */
async function saveCustomCSS(css: string) {
customCSS.value = css
@@ -461,6 +514,7 @@ const getThemeIcon = computed(() => {
onMounted(() => {
getCustomCSS()
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
// 初始化透明度设置
if (isTransparentTheme.value) {
@@ -479,6 +533,7 @@ onUnmounted(() => {
closeRestartProgress()
siteAuthDialogController?.close()
customCssDialogController?.close()
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
})
</script>
@@ -587,7 +642,7 @@ onUnmounted(() => {
</template>
<VListItemTitle>{{ t('common.theme') }}</VListItemTitle>
<VListItemSubtitle>
{{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }}
{{ currentThemeSummary }}
</VListItemSubtitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
@@ -595,27 +650,32 @@ onUnmounted(() => {
</VListItem>
</template>
<VList>
<VListItem
v-for="theme in themes"
:key="theme.name"
@click="changeTheme(theme.name)"
:active="currentThemeName === theme.name"
class="mb-1"
>
<VListItem v-if="!appMode" @click="showThemeCustomizerDrawer">
<template #prepend>
<VIcon :icon="theme.icon" />
<VIcon icon="mdi-tune-variant" />
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
<template #append v-if="currentThemeName === theme.name">
<VIcon icon="mdi-check" color="primary" size="small" />
<VListItemTitle>{{ t('theme.customizer.title') }}</VListItemTitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
<VListItem @click="showCustomCssDialog">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>{{ t('theme.custom') }}</VListItemTitle>
</VListItem>
<template v-else>
<VListItem
v-for="theme in themes"
:key="theme.name"
@click="changeTheme(theme.name)"
:active="currentThemeName === theme.name"
class="mb-1"
>
<template #prepend>
<VIcon :icon="theme.icon" />
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
<template #append v-if="currentThemeName === theme.name">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
</template>
<!-- 透明度调整 - 仅在透明主题下显示 -->
<template v-if="isTransparentTheme">
@@ -629,6 +689,16 @@ onUnmounted(() => {
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
<VListItem @click="showCustomCssDialog">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>{{ t('theme.custom') }}</VListItemTitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
</template>
</VList>
</VMenu>
@@ -707,6 +777,7 @@ onUnmounted(() => {
</VMenu>
<!-- !SECTION -->
</VAvatar>
<ThemeCustomizer v-model="showThemeCustomizer" />
</template>
<style lang="scss" scoped>

View File

@@ -158,6 +158,24 @@ export default {
customCssSaveSuccess: 'Custom CSS saved successfully, please refresh the page to take effect!',
customCssSaveFailed: 'Failed to save custom CSS to server',
deviceNotSupport: 'Current device does not support monitoring system theme changes',
customizer: {
title: 'Theme Customizer',
subtitle: 'Customize & Preview in Real Time',
theming: 'Theming',
primaryColor: 'Primary Color',
usePrimaryColor: 'Use {color} primary color',
chooseCustomColor: 'Choose custom primary color',
skins: 'Skins',
skinDefault: 'Default',
skinBordered: 'Bordered',
semiDarkMenu: 'Semi Dark Menu',
layout: 'Layout',
layoutVertical: 'Vertical',
layoutCollapsed: 'Collapsed',
layoutHorizontal: 'Horizontal',
reset: 'Reset theme customizer',
appModeLayoutLocked: 'App mode keeps mobile navigation fixed. Switch to desktop UI mode to customize layout.',
},
},
app: {
moviepilot: 'MoviePilot',

View File

@@ -158,6 +158,24 @@ export default {
customCssSaveSuccess: '自定义CSS保存成功请刷新页面生效',
customCssSaveFailed: '保存自定义CSS到服务端失败',
deviceNotSupport: '当前设备不支持监听系统主题变化',
customizer: {
title: '主题定制器',
subtitle: '实时自定义与预览',
theming: '主题',
primaryColor: '主色',
usePrimaryColor: '使用 {color} 主色',
chooseCustomColor: '选择自定义主色',
skins: '皮肤',
skinDefault: '默认',
skinBordered: '边框',
semiDarkMenu: '半暗菜单',
layout: '布局',
layoutVertical: '垂直',
layoutCollapsed: '折叠',
layoutHorizontal: '水平',
reset: '重置主题定制',
appModeLayoutLocked: 'App 模式固定使用移动端导航,请切换到桌面界面后再调整布局。',
},
},
app: {
moviepilot: 'MoviePilot',

View File

@@ -158,6 +158,24 @@ export default {
customCssSaveSuccess: '自定義CSS保存成功請刷新頁面生效',
customCssSaveFailed: '保存自定義CSS到服務端失敗',
deviceNotSupport: '當前設備不支持監聽系統主題變化',
customizer: {
title: '主題定制器',
subtitle: '即時自定義與預覽',
theming: '主題',
primaryColor: '主色',
usePrimaryColor: '使用 {color} 主色',
chooseCustomColor: '選擇自定義主色',
skins: '皮膚',
skinDefault: '默認',
skinBordered: '邊框',
semiDarkMenu: '半暗菜單',
layout: '佈局',
layoutVertical: '垂直',
layoutCollapsed: '折疊',
layoutHorizontal: '水平',
reset: '重置主題定制',
appModeLayoutLocked: 'App 模式固定使用移動端導航,請切換到桌面界面後再調整佈局。',
},
},
app: {
moviepilot: 'MoviePilot',

View File

@@ -68,6 +68,35 @@ html {
}
}
// 主题定制器的 bordered 皮肤:保持原布局密度,只给主要容器增加清晰边界。
html[data-theme-skin='bordered'] {
.v-card:not(.bg-primary),
.v-sheet,
.v-table,
.v-expansion-panel,
.v-list {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1) !important;
box-shadow: none !important;
}
.layout-vertical-nav,
.navbar-content-container,
.footer-nav-card {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1) !important;
}
.layout-vertical-nav {
border-block: 0 !important;
border-inline-start: 0 !important;
}
.navbar-content-container {
border-block-start: 0 !important;
border-radius: 0 0 10px 10px;
background-color: rgba(var(--v-theme-surface), 0.82);
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形。
.app-card-shell {
position: relative;