feat: refine theme customizer and horizontal navigation

This commit is contained in:
jxxghp
2026-06-02 17:56:41 +08:00
parent d02ece234c
commit b639737bd6
4 changed files with 283 additions and 45 deletions

View File

@@ -117,6 +117,7 @@ export default defineComponent({
// 👉 根据路由 meta 决定 footer 高度
const shouldShowFooter = !route.meta.hideFooter
const isNavbarScrolled = scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)
// 👉 Footer
const footer = h('footer', { class: 'layout-footer' }, [
@@ -146,8 +147,9 @@ export default defineComponent({
mdAndDown.value && 'layout-overlay-nav',
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
isHorizontalLayout.value && 'layout-horizontal-nav-active',
isHorizontalLayout.value && isNavbarScrolled && 'layout-horizontal-nav-scrolled',
route.meta.layoutWrapperClasses,
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
!isHorizontalLayout.value && isNavbarScrolled && 'window-scrolled',
],
},
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
@@ -340,11 +342,80 @@ export default defineComponent({
}
@at-root {
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed .layout-navbar {
backdrop-filter: blur(12px) saturate(1.2);
background: rgb(var(--v-theme-surface)) !important;
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
}
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
filter: none !important;
padding-inline: 1.5rem !important;
&::before {
display: none !important;
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
content: none !important;
filter: none !important;
}
}
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);
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
border-block-end-color: rgba(var(--v-theme-on-surface), 0.04);
}
html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .navbar-content-container,
.v-theme--transparent .layout-wrapper.layout-horizontal-nav-active .navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
}
// 透明主题的水平导航不叠加滚动磨砂层,避免中间区域出现一块更深的背景。
html[data-theme='transparent']
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.layout-navbar,
.v-theme--transparent
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.layout-navbar {
backdrop-filter: blur(var(--transparent-blur-light, 6px)) !important;
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2)) !important;
box-shadow: none !important;
}
// 透明主题滚动时只让外层导航栏承载整屏背景,避免内部最大宽度容器单独变深。
html[data-theme='transparent']
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container,
.v-theme--transparent
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
filter: none !important;
padding-inline: 1.5rem !important;
&::before {
display: none !important;
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
content: none !important;
filter: none !important;
}
}
html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='vertical']

View File

@@ -240,24 +240,39 @@ function handleLayoutChange(layout: ThemeCustomizerLayout) {
<style lang="scss">
.theme-customizer-drawer {
position: fixed !important;
z-index: 12000 !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
block-size: 100dvh !important;
box-shadow: -2px 0 6px rgba(0, 0, 0, 10%) !important;
inset-block: 0 !important;
inset-inline-end: 0 !important;
max-block-size: 100dvh !important;
.v-navigation-drawer__content {
display: flex;
overflow: hidden;
flex-direction: column;
block-size: 100%;
}
}
.theme-customizer-drawer .v-theme--transparent,
.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;
}
// 透明主题的全局 overlay 毛玻璃会影响临时抽屉绘制,主题定制器改由 drawer 自身承担背景。
html[data-theme='transparent'] .v-overlay__content:has(.theme-customizer-drawer),
.v-theme--transparent .v-overlay__content:has(.theme-customizer-drawer) {
border-radius: 0 !important;
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
}
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-card-option .theme-customizer-theme-icon,
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-color-option .theme-customizer-native-icon,
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-header-icon {

View File

@@ -10,12 +10,20 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
import QuickAccess from '@/layouts/components/QuickAccess.vue'
import HeaderTab from '@/layouts/components/HeaderTab.vue'
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
import { getNavMenus } from '@/router/i18n-menu'
import {
getDiscoverTabs,
getNavMenus,
getPluginTabs,
getSettingTabs,
getSubscribeMovieTabs,
getSubscribeTvTabs,
getWorkflowTabs,
} from '@/router/i18n-menu'
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { filterMenusByPermission } from '@/utils/permission'
import { onUnreadMessage } from '@/utils/badge'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
@@ -34,6 +42,7 @@ const display = useDisplay()
const { appMode } = usePWA()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const themeLayout = ref(readThemeCustomizerSettings().layout)
// 用户 Store
@@ -133,6 +142,7 @@ interface DynamicHeaderTab {
// 提供动态标签页注册和获取的方法
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
const openHorizontalNavGroup = ref<string | null>(null)
const pendingHorizontalTab = ref<{ path: string; tab: string } | null>(null)
// 提供一个方法让其他组件注册动态标签页
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
@@ -140,6 +150,7 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
tab.routePath = route.path
// 强制更新,确保响应式系统能检测到变化
dynamicHeaderTab.value = { ...tab }
applyPendingHorizontalTab()
}
// 提供一个方法让其他组件取消注册动态标签页
@@ -199,6 +210,20 @@ const visibleHorizontalHeaderButtons = computed(() => {
return (dynamicHeaderTab.value?.appendButtons ?? []).filter(button => resolveMaybeRefValue(button.show, true) !== false)
})
const staticHorizontalNavTabs = computed<Record<string, DynamicHeaderTabItem[]>>(() => ({
'/recommend': getRecommendTabs(),
'/discover': getDiscoverTabs(t).map(tab => ({
title: tab.name,
icon: tab.icon,
tab: tab.tab,
})),
'/subscribe/movie': getSubscribeMovieTabs(t),
'/subscribe/tv': getSubscribeTvTabs(t),
'/workflow': getWorkflowTabs(t),
'/plugins': getPluginTabs(t),
'/setting': getSettingTabs(t),
}))
// 在组件销毁时清理
onUnmounted(() => {
dynamicHeaderTab.value = null
@@ -269,10 +294,10 @@ function handleThemeCustomizerChange(event: Event) {
}
function isHorizontalNavActive(item: NavMenu) {
if (typeof item.to !== 'string') return false
const targetPath = normalizeMenuPath(item.to)
if (!targetPath) return false
const targetPath = item.to.replace(/\/$/, '')
const currentPath = route.path.replace(/\/$/, '')
const currentPath = normalizeMenuPath(route.path)
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
}
@@ -282,15 +307,25 @@ function isHorizontalNavGroupActive(group: { items: NavMenu[] }) {
}
function hasHorizontalDynamicTabs(item: NavMenu) {
return showHorizontalThemeNav.value && hasDynamicHeaderTab.value && isHorizontalNavActive(item)
return showHorizontalThemeNav.value && getHorizontalNavTabs(item).length > 0
}
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
return dynamicHeaderTab.value?.modelValue === tab.tab
}
function handleHorizontalDynamicTabSelect(tab: DynamicHeaderTabItem) {
handleTabChange(tab.tab)
async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeaderTabItem) {
const targetPath = normalizeMenuPath(item.to)
const currentPath = normalizeMenuPath(route.path)
if (targetPath && currentPath !== targetPath) {
// 三级菜单可能在目标页面挂载前点击,先记录待切换 tab页面注册动态 tab 后再应用。
pendingHorizontalTab.value = { path: targetPath, tab: tab.tab }
await router.push(targetPath)
} else {
handleTabChange(tab.tab)
}
openHorizontalNavGroup.value = null
}
@@ -310,6 +345,57 @@ function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
return resolveMaybeRefValue(button.loading, false)
}
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
const icon = tab.icon?.trim()
// 部分页面会把业务来源标识(如 themoviedb/douban/bangumi放进 icon 字段,
// 这些值不是菜单里的可渲染图标,三级菜单统一回退到默认图标。
if (!icon || (!icon.startsWith('mdi-') && !icon.startsWith('tabler-') && !icon.includes(':'))) {
return 'mdi-circle-medium'
}
return icon
}
function normalizeMenuPath(value: unknown) {
if (typeof value !== 'string') return ''
return value.replace(/\/$/, '') || '/'
}
function getHorizontalNavTabs(item: NavMenu): DynamicHeaderTabItem[] {
const targetPath = normalizeMenuPath(item.to)
if (targetPath && isHorizontalNavActive(item) && hasDynamicHeaderTab.value) {
return dynamicHeaderTab.value?.items ?? []
}
return staticHorizontalNavTabs.value[targetPath] ?? []
}
function getRecommendTabs(): DynamicHeaderTabItem[] {
return [
{ title: t('recommend.all'), icon: 'mdi-filmstrip-box-multiple', tab: t('recommend.all') },
{ title: t('recommend.categoryMovie'), icon: 'mdi-movie', tab: t('recommend.categoryMovie') },
{ title: t('recommend.categoryTV'), icon: 'mdi-television-classic', tab: t('recommend.categoryTV') },
{ title: t('recommend.categoryAnime'), icon: 'mdi-animation', tab: t('recommend.categoryAnime') },
{ title: t('recommend.categoryRankings'), icon: 'mdi-trophy', tab: t('recommend.categoryRankings') },
]
}
function applyPendingHorizontalTab() {
if (!pendingHorizontalTab.value || !hasDynamicHeaderTab.value) return
const pending = pendingHorizontalTab.value
if (normalizeMenuPath(route.path) !== pending.path) return
const tabExists = dynamicHeaderTab.value?.items.some(item => item.tab === pending.tab)
if (!tabExists) return
handleTabChange(pending.tab)
pendingHorizontalTab.value = null
}
// 处理未读消息事件
function handleUnreadMessage(count: number) {
if (superUser.value && count > 0) {
@@ -439,15 +525,22 @@ onMounted(async () => {
<VIcon icon="mdi-arrow-left" size="32" />
</IconBtn>
<!-- 👉 Search Bar -->
<SearchBar />
<SearchBar v-if="!showHorizontalThemeNav" />
<!-- 👉 Spacer -->
<VSpacer />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
<!-- 👉 Notification -->
<UserNofification />
<!-- 👉 UserProfile -->
<UserProfile />
<div
class="theme-navbar-actions d-flex align-center"
:class="{ 'theme-navbar-actions--horizontal': showHorizontalThemeNav }"
>
<!-- 👉 Horizontal Search Icon -->
<SearchBar v-if="showHorizontalThemeNav" icon-only />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
<!-- 👉 Notification -->
<UserNofification />
<!-- 👉 UserProfile -->
<UserProfile />
</div>
</div>
<div v-if="showHorizontalThemeNav" class="theme-horizontal-nav">
<VMenu
@@ -479,6 +572,9 @@ onMounted(async () => {
v-if="hasHorizontalDynamicTabs(item)"
location="end top"
offset="8"
open-on-hover
:open-delay="0"
:close-delay="120"
:close-on-content-click="true"
>
<template #activator="{ props: subMenuProps }">
@@ -495,13 +591,13 @@ onMounted(async () => {
<VList class="theme-horizontal-nav__submenu" min-width="12rem" density="comfortable">
<VListItem
v-for="tab in dynamicHeaderTab!.items"
v-for="tab in getHorizontalNavTabs(item)"
:key="`${item.to}-${tab.tab}`"
:active="isHorizontalDynamicTabActive(tab)"
@click="handleHorizontalDynamicTabSelect(tab)"
@click="handleHorizontalDynamicTabSelect(item, tab)"
>
<template v-if="tab.icon" #prepend>
<VIcon :icon="tab.icon" />
<template #prepend>
<VIcon :icon="getHorizontalTabIcon(tab)" />
</template>
<VListItemTitle>{{ tab.title }}</VListItemTitle>
</VListItem>
@@ -677,6 +773,35 @@ onMounted(async () => {
white-space: nowrap;
}
.theme-navbar-actions--horizontal {
gap: 0.85rem;
:deep(.ms-2),
:deep(.ms-3) {
margin-inline-start: 0 !important;
}
:deep(.v-btn.v-btn--icon) {
flex: 0 0 auto;
border-radius: 12px;
block-size: 2.75rem;
color: rgba(var(--v-theme-on-surface), 0.78);
inline-size: 2.75rem;
}
:deep(.v-btn.v-btn--icon .v-icon) {
font-size: 1.75rem;
line-height: 1;
}
:deep(.v-avatar.cursor-pointer) {
flex: 0 0 auto;
block-size: 2.75rem !important;
inline-size: 2.75rem !important;
margin-inline-start: 0 !important;
}
}
.theme-horizontal-nav {
display: flex;
overflow-x: auto;

View File

@@ -5,6 +5,15 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
defineProps<{
iconOnly?: boolean
}>(),
{
iconOnly: false,
},
)
const display = useDisplay()
const { t } = useI18n()
@@ -23,17 +32,18 @@ function isMac() {
}
// 计算属性:根据操作系统显示不同的按键提示
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
const showIconOnly = computed(() => props.iconOnly || !display.mdAndUp.value)
</script>
<template>
<!-- 小屏仅图标按钮 -->
<IconBtn v-if="!display.mdAndUp.value" @click="openSearchDialog">
<VIcon icon="mdi-magnify" />
<!-- 小屏或水平导航右侧工具区仅显示搜索图标 -->
<IconBtn v-if="showIconOnly" class="search-icon-trigger" @click="openSearchDialog">
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
</IconBtn>
<!-- 中屏及以上胶囊搜索触发栏 -->
<div v-else class="search-trigger" @click="openSearchDialog">
<VIcon icon="mdi-magnify" size="18" class="search-trigger-icon" />
<VIcon icon="mdi-magnify" size="30" class="search-trigger-icon" />
<span class="search-trigger-text">{{ t('common.search') }}</span>
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
</div>
@@ -43,12 +53,14 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
.search-trigger {
display: flex;
align-items: center;
border: 1.5px solid rgba(var(--v-theme-primary), 0.6);
border-radius: 22px;
block-size: 36px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 999px;
background: rgba(var(--v-theme-surface), 0.44);
block-size: 44px;
cursor: pointer;
gap: 8px;
padding-inline: 12px;
gap: 12px;
min-inline-size: 168px;
padding-inline: 18px 10px;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
@@ -57,34 +69,49 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
}
.search-trigger:hover {
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%);
border-color: rgba(var(--v-theme-on-surface), 0.18);
background-color: rgba(var(--v-theme-surface), 0.62);
box-shadow: 0 4px 14px rgba(0, 0, 0, 6%);
}
.search-trigger-icon {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.4);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 1.8rem;
}
.search-trigger-text {
color: rgba(var(--v-theme-on-surface), 0.4);
font-size: 13.5px;
color: rgba(var(--v-theme-on-surface), 0.42);
font-size: 1rem;
line-height: 1;
white-space: nowrap;
}
.search-trigger-kbd {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 5px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.4);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.5);
color: rgba(var(--v-theme-on-surface), 0.42);
font-family: inherit;
font-size: 11px;
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
margin-inline-start: 4px;
padding-block: 3px;
padding-inline: 5px;
padding-block: 6px;
padding-inline: 8px;
}
html[data-theme='transparent'] .search-trigger,
.v-theme--transparent .search-trigger {
backdrop-filter: none;
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
}
.search-icon-trigger {
flex: 0 0 auto;
}
.search-icon-trigger__icon {
transform: scaleX(-1);
}
</style>