mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +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 { Transition } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
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({
|
export default defineComponent({
|
||||||
setup(props, { slots }) {
|
setup(props, { slots }) {
|
||||||
@@ -11,6 +17,11 @@ export default defineComponent({
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { mdAndDown } = useDisplay()
|
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
|
// ℹ️ 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.
|
// 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
|
scrollDistance.value = window.scrollY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleThemeCustomizerChange = (event: Event) => {
|
||||||
|
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
|
||||||
|
}
|
||||||
|
|
||||||
// 监听弹窗状态变化
|
// 监听弹窗状态变化
|
||||||
const checkDialogState = () => {
|
const checkDialogState = () => {
|
||||||
const wasDialogOpen = isDialogOpen.value
|
const wasDialogOpen = isDialogOpen.value
|
||||||
@@ -38,6 +53,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('scroll', handleScroll)
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||||
|
|
||||||
// 初始检查弹窗状态
|
// 初始检查弹窗状态
|
||||||
checkDialogState()
|
checkDialogState()
|
||||||
@@ -52,6 +68,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||||
dialogObserver?.disconnect()
|
dialogObserver?.disconnect()
|
||||||
dialogObserver = null
|
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-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
|
||||||
'layout-navbar-fixed',
|
'layout-navbar-fixed',
|
||||||
mdAndDown.value && 'layout-overlay-nav',
|
mdAndDown.value && 'layout-overlay-nav',
|
||||||
|
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
|
||||||
|
isHorizontalLayout.value && 'layout-horizontal-nav-active',
|
||||||
route.meta.layoutWrapperClasses,
|
route.meta.layoutWrapperClasses,
|
||||||
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
|
(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
|
// Adjust right column pl when vertical nav is collapsed
|
||||||
&.layout-vertical-nav-collapsed .layout-content-wrapper {
|
&.layout-vertical-nav-collapsed .layout-content-wrapper {
|
||||||
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
|
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
|
// 👉 Content height fixed
|
||||||
|
|||||||
+6
-1
@@ -12,16 +12,19 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
|||||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||||
|
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||||
import { themeManager } from '@/utils/themeManager'
|
import { themeManager } from '@/utils/themeManager'
|
||||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||||
|
|
||||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||||
|
|
||||||
// 生效主题
|
// 生效主题
|
||||||
const { global: globalTheme } = useTheme()
|
const vuetifyTheme = useTheme()
|
||||||
|
const { global: globalTheme } = vuetifyTheme
|
||||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||||
|
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||||
|
|
||||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||||
function syncRootLaunchPalette() {
|
function syncRootLaunchPalette() {
|
||||||
@@ -285,6 +288,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 初始化主题管理器 - 统一处理主题初始化
|
// 初始化主题管理器 - 统一处理主题初始化
|
||||||
await themeManager.setTheme(themeValue)
|
await themeManager.setTheme(themeValue)
|
||||||
|
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||||
|
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||||
|
|
||||||
// 监听主题变化
|
// 监听主题变化
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -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 {
|
.search-input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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;
|
border-radius: 28px;
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.04);
|
background-color: rgba(var(--v-theme-surface-variant), 0.04);
|
||||||
block-size: 48px;
|
block-size: 48px;
|
||||||
@@ -723,7 +723,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input-wrapper:focus-within {
|
.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);
|
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { usePWA } from '@/composables/usePWA'
|
||||||
import OfflinePage from '@/layouts/components/OfflinePage.vue'
|
import OfflinePage from '@/layouts/components/OfflinePage.vue'
|
||||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
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()
|
const display = useDisplay()
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -60,6 +67,35 @@ const organizeMenus = ref<NavMenu[]>([])
|
|||||||
// 系统菜单项
|
// 系统菜单项
|
||||||
const systemMenus = 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)
|
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 {
|
interface DynamicHeaderTab {
|
||||||
items: Array<{ title: string; icon: string; tab: string }>
|
items: DynamicHeaderTabItem[]
|
||||||
modelValue: string
|
modelValue: string
|
||||||
appendButtons?: Array<{
|
appendButtons?: 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
|
|
||||||
}>
|
|
||||||
routePath?: string // 用于标识哪个路由注册的
|
routePath?: string // 用于标识哪个路由注册的
|
||||||
onUpdateModelValue?: (value: string) => void // 用于通知值更新
|
onUpdateModelValue?: (value: string) => void // 用于通知值更新
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供动态标签页注册和获取的方法
|
// 提供动态标签页注册和获取的方法
|
||||||
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
|
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
|
||||||
|
const openHorizontalNavGroup = ref<string | null>(null)
|
||||||
|
|
||||||
// 提供一个方法让其他组件注册动态标签页
|
// 提供一个方法让其他组件注册动态标签页
|
||||||
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
||||||
@@ -138,13 +183,22 @@ watch(
|
|||||||
{ immediate: false },
|
{ immediate: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 显示动态标签页
|
// 当前路由是否注册了动态标签页。
|
||||||
const showDynamicHeaderTab = computed(() => {
|
const hasDynamicHeaderTab = computed(() => {
|
||||||
return (
|
return (
|
||||||
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
|
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(() => {
|
onUnmounted(() => {
|
||||||
dynamicHeaderTab.value = null
|
dynamicHeaderTab.value = null
|
||||||
@@ -210,6 +264,52 @@ function goBack() {
|
|||||||
history.back()
|
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) {
|
function handleUnreadMessage(count: number) {
|
||||||
if (superUser.value && count > 0) {
|
if (superUser.value && count > 0) {
|
||||||
@@ -278,9 +378,12 @@ onMounted(async () => {
|
|||||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||||
|
|
||||||
// 组件卸载时清理监听
|
// 组件卸载时清理监听
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||||
}
|
}
|
||||||
@@ -316,10 +419,17 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VerticalNavLayout :style="{ '--navbar-tab-height': showDynamicHeaderTab ? '2.5rem' : '0px' }">
|
<VerticalNavLayout :style="{ '--navbar-tab-height': navbarExtraHeight }">
|
||||||
<!-- 👉 Navbar -->
|
<!-- 👉 Navbar -->
|
||||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
<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 -->
|
<!-- 👉 Vertical Nav Toggle -->
|
||||||
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
|
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
|
||||||
<VIcon icon="mdi-menu" />
|
<VIcon icon="mdi-menu" />
|
||||||
@@ -339,6 +449,94 @@ onMounted(async () => {
|
|||||||
<!-- 👉 UserProfile -->
|
<!-- 👉 UserProfile -->
|
||||||
<UserProfile />
|
<UserProfile />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<template #vertical-nav-content>
|
<template #vertical-nav-content>
|
||||||
@@ -412,7 +610,7 @@ onMounted(async () => {
|
|||||||
:style="{
|
:style="{
|
||||||
transform: contentTransform,
|
transform: contentTransform,
|
||||||
transition: contentTransition,
|
transition: contentTransition,
|
||||||
paddingTop: showDynamicHeaderTab ? '3rem' : '0px',
|
paddingTop: mainContentPaddingTop,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
@@ -443,6 +641,74 @@ onMounted(async () => {
|
|||||||
will-change: transform;
|
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 {
|
.pull-indicator {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const props = defineProps({
|
|||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
type: Array as PropType<{ title: string; icon: string; tab: string }[]>,
|
type: Array as PropType<{ title: string; icon?: string; tab: string }[]>,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
// 新增:是否启用PWA状态恢复
|
// 新增:是否启用PWA状态恢复
|
||||||
|
|||||||
@@ -43,25 +43,28 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
|||||||
.search-trigger {
|
.search-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
border: 1.5px solid rgba(var(--v-theme-primary), 0.6);
|
||||||
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.12);
|
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
block-size: 36px;
|
block-size: 36px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
gap: 8px;
|
||||||
padding-inline: 12px;
|
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;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger:hover {
|
.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);
|
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger-icon {
|
.search-trigger-icon {
|
||||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger-text {
|
.search-trigger-text {
|
||||||
|
|||||||
@@ -16,11 +16,20 @@ import { useConfirm } from '@/composables/useConfirm'
|
|||||||
import { themeManager } from '@/utils/themeManager'
|
import { themeManager } from '@/utils/themeManager'
|
||||||
import { usePWA, type UIMode } from '@/composables/usePWA'
|
import { usePWA, type UIMode } from '@/composables/usePWA'
|
||||||
import { applyStoredTransparencySettings } from '@/composables/useTransparencySettings'
|
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 AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
||||||
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
|
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
|
||||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.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'))
|
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
|
||||||
|
|
||||||
// 认证 Store
|
// 认证 Store
|
||||||
@@ -32,7 +41,7 @@ const globalSettingsStore = useGlobalSettingsStore()
|
|||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
// PWA
|
// PWA
|
||||||
const { uiMode, setUIMode } = usePWA()
|
const { appMode, uiMode, setUIMode } = usePWA()
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
@@ -43,6 +52,9 @@ const showUIModeMenu = ref(false)
|
|||||||
// 主题菜单是否显示
|
// 主题菜单是否显示
|
||||||
const showThemeMenu = ref(false)
|
const showThemeMenu = ref(false)
|
||||||
|
|
||||||
|
// 主题定制器面板是否显示
|
||||||
|
const showThemeCustomizer = ref(false)
|
||||||
|
|
||||||
// 语言菜单是否显示
|
// 语言菜单是否显示
|
||||||
const showLanguageMenu = ref(false)
|
const showLanguageMenu = ref(false)
|
||||||
|
|
||||||
@@ -264,6 +276,7 @@ const getUIModeIcon = computed(() => {
|
|||||||
const { name: themeName, global: globalTheme } = useTheme()
|
const { name: themeName, global: globalTheme } = useTheme()
|
||||||
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
|
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
|
||||||
const currentThemeName = ref(savedTheme.value)
|
const currentThemeName = ref(savedTheme.value)
|
||||||
|
const themeCustomizerSettings = ref(readThemeCustomizerSettings())
|
||||||
|
|
||||||
const themes: ThemeSwitcherTheme[] = [
|
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 模式或弹窗打开后切主题时颜色不同步。
|
// Ace 跟随 Vuetify 当前生效主题,避免 auto 模式或弹窗打开后切主题时颜色不同步。
|
||||||
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
|
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
|
||||||
|
|
||||||
@@ -328,6 +360,7 @@ async function changeTheme(theme: string) {
|
|||||||
|
|
||||||
// 保存主题到服务端
|
// 保存主题到服务端
|
||||||
try {
|
try {
|
||||||
|
persistPartialThemeCustomizerSettings({ theme: theme as ThemeCustomizerSettings['theme'] })
|
||||||
api.post('/user/config/Layout', {
|
api.post('/user/config/Layout', {
|
||||||
theme,
|
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
|
// 获取自定义 CSS
|
||||||
async function getCustomCSS() {
|
async function getCustomCSS() {
|
||||||
try {
|
try {
|
||||||
@@ -379,6 +424,14 @@ function showTransparencySettingsDialog() {
|
|||||||
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从用户菜单打开主题定制器,App 模式下入口不显示,这里仍保留保护。 */
|
||||||
|
function showThemeCustomizerDrawer() {
|
||||||
|
if (appMode.value) return
|
||||||
|
|
||||||
|
showThemeMenu.value = false
|
||||||
|
showThemeCustomizer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
/** 保存自定义 CSS。 */
|
/** 保存自定义 CSS。 */
|
||||||
async function saveCustomCSS(css: string) {
|
async function saveCustomCSS(css: string) {
|
||||||
customCSS.value = css
|
customCSS.value = css
|
||||||
@@ -461,6 +514,7 @@ const getThemeIcon = computed(() => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getCustomCSS()
|
getCustomCSS()
|
||||||
|
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
|
||||||
|
|
||||||
// 初始化透明度设置
|
// 初始化透明度设置
|
||||||
if (isTransparentTheme.value) {
|
if (isTransparentTheme.value) {
|
||||||
@@ -479,6 +533,7 @@ onUnmounted(() => {
|
|||||||
closeRestartProgress()
|
closeRestartProgress()
|
||||||
siteAuthDialogController?.close()
|
siteAuthDialogController?.close()
|
||||||
customCssDialogController?.close()
|
customCssDialogController?.close()
|
||||||
|
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -587,7 +642,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<VListItemTitle>{{ t('common.theme') }}</VListItemTitle>
|
<VListItemTitle>{{ t('common.theme') }}</VListItemTitle>
|
||||||
<VListItemSubtitle>
|
<VListItemSubtitle>
|
||||||
{{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }}
|
{{ currentThemeSummary }}
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
<VIcon icon="mdi-chevron-right" size="small" />
|
<VIcon icon="mdi-chevron-right" size="small" />
|
||||||
@@ -595,27 +650,32 @@ onUnmounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
</template>
|
</template>
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem v-if="!appMode" @click="showThemeCustomizerDrawer">
|
||||||
v-for="theme in themes"
|
|
||||||
:key="theme.name"
|
|
||||||
@click="changeTheme(theme.name)"
|
|
||||||
:active="currentThemeName === theme.name"
|
|
||||||
class="mb-1"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon :icon="theme.icon" />
|
<VIcon icon="mdi-tune-variant" />
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle>{{ theme.title }}</VListItemTitle>
|
<VListItemTitle>{{ t('theme.customizer.title') }}</VListItemTitle>
|
||||||
<template #append v-if="currentThemeName === theme.name">
|
<template #append>
|
||||||
<VIcon icon="mdi-check" color="primary" size="small" />
|
<VIcon icon="mdi-chevron-right" size="small" />
|
||||||
</template>
|
</template>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem @click="showCustomCssDialog">
|
<template v-else>
|
||||||
<template #prepend>
|
<VListItem
|
||||||
<VIcon icon="mdi-palette" />
|
v-for="theme in themes"
|
||||||
</template>
|
:key="theme.name"
|
||||||
<VListItemTitle>{{ t('theme.custom') }}</VListItemTitle>
|
@click="changeTheme(theme.name)"
|
||||||
</VListItem>
|
: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">
|
<template v-if="isTransparentTheme">
|
||||||
@@ -629,6 +689,16 @@ onUnmounted(() => {
|
|||||||
<VIcon icon="mdi-chevron-right" size="small" />
|
<VIcon icon="mdi-chevron-right" size="small" />
|
||||||
</template>
|
</template>
|
||||||
</VListItem>
|
</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>
|
</template>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
@@ -707,6 +777,7 @@ onUnmounted(() => {
|
|||||||
</VMenu>
|
</VMenu>
|
||||||
<!-- !SECTION -->
|
<!-- !SECTION -->
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
|
<ThemeCustomizer v-model="showThemeCustomizer" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -158,6 +158,24 @@ export default {
|
|||||||
customCssSaveSuccess: 'Custom CSS saved successfully, please refresh the page to take effect!',
|
customCssSaveSuccess: 'Custom CSS saved successfully, please refresh the page to take effect!',
|
||||||
customCssSaveFailed: 'Failed to save custom CSS to server',
|
customCssSaveFailed: 'Failed to save custom CSS to server',
|
||||||
deviceNotSupport: 'Current device does not support monitoring system theme changes',
|
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: {
|
app: {
|
||||||
moviepilot: 'MoviePilot',
|
moviepilot: 'MoviePilot',
|
||||||
|
|||||||
@@ -158,6 +158,24 @@ export default {
|
|||||||
customCssSaveSuccess: '自定义CSS保存成功,请刷新页面生效!',
|
customCssSaveSuccess: '自定义CSS保存成功,请刷新页面生效!',
|
||||||
customCssSaveFailed: '保存自定义CSS到服务端失败',
|
customCssSaveFailed: '保存自定义CSS到服务端失败',
|
||||||
deviceNotSupport: '当前设备不支持监听系统主题变化',
|
deviceNotSupport: '当前设备不支持监听系统主题变化',
|
||||||
|
customizer: {
|
||||||
|
title: '主题定制器',
|
||||||
|
subtitle: '实时自定义与预览',
|
||||||
|
theming: '主题',
|
||||||
|
primaryColor: '主色',
|
||||||
|
usePrimaryColor: '使用 {color} 主色',
|
||||||
|
chooseCustomColor: '选择自定义主色',
|
||||||
|
skins: '皮肤',
|
||||||
|
skinDefault: '默认',
|
||||||
|
skinBordered: '边框',
|
||||||
|
semiDarkMenu: '半暗菜单',
|
||||||
|
layout: '布局',
|
||||||
|
layoutVertical: '垂直',
|
||||||
|
layoutCollapsed: '折叠',
|
||||||
|
layoutHorizontal: '水平',
|
||||||
|
reset: '重置主题定制',
|
||||||
|
appModeLayoutLocked: 'App 模式固定使用移动端导航,请切换到桌面界面后再调整布局。',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
moviepilot: 'MoviePilot',
|
moviepilot: 'MoviePilot',
|
||||||
|
|||||||
@@ -158,6 +158,24 @@ export default {
|
|||||||
customCssSaveSuccess: '自定義CSS保存成功,請刷新頁面生效!',
|
customCssSaveSuccess: '自定義CSS保存成功,請刷新頁面生效!',
|
||||||
customCssSaveFailed: '保存自定義CSS到服務端失敗',
|
customCssSaveFailed: '保存自定義CSS到服務端失敗',
|
||||||
deviceNotSupport: '當前設備不支持監聽系統主題變化',
|
deviceNotSupport: '當前設備不支持監聽系統主題變化',
|
||||||
|
customizer: {
|
||||||
|
title: '主題定制器',
|
||||||
|
subtitle: '即時自定義與預覽',
|
||||||
|
theming: '主題',
|
||||||
|
primaryColor: '主色',
|
||||||
|
usePrimaryColor: '使用 {color} 主色',
|
||||||
|
chooseCustomColor: '選擇自定義主色',
|
||||||
|
skins: '皮膚',
|
||||||
|
skinDefault: '默認',
|
||||||
|
skinBordered: '邊框',
|
||||||
|
semiDarkMenu: '半暗菜單',
|
||||||
|
layout: '佈局',
|
||||||
|
layoutVertical: '垂直',
|
||||||
|
layoutCollapsed: '折疊',
|
||||||
|
layoutHorizontal: '水平',
|
||||||
|
reset: '重置主題定制',
|
||||||
|
appModeLayoutLocked: 'App 模式固定使用移動端導航,請切換到桌面界面後再調整佈局。',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
moviepilot: 'MoviePilot',
|
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 {
|
.app-card-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user