mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-22 08:03:45 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
544
src/components/ThemeCustomizer.vue
Normal file
544
src/components/ThemeCustomizer.vue
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
313
src/composables/useThemeCustomizer.ts
Normal file
313
src/composables/useThemeCustomizer.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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状态恢复
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user