mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
feat: refine theme customizer and horizontal navigation
This commit is contained in:
@@ -117,6 +117,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
// 👉 根据路由 meta 决定 footer 高度
|
// 👉 根据路由 meta 决定 footer 高度
|
||||||
const shouldShowFooter = !route.meta.hideFooter
|
const shouldShowFooter = !route.meta.hideFooter
|
||||||
|
const isNavbarScrolled = scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)
|
||||||
|
|
||||||
// 👉 Footer
|
// 👉 Footer
|
||||||
const footer = h('footer', { class: 'layout-footer' }, [
|
const footer = h('footer', { class: 'layout-footer' }, [
|
||||||
@@ -146,8 +147,9 @@ export default defineComponent({
|
|||||||
mdAndDown.value && 'layout-overlay-nav',
|
mdAndDown.value && 'layout-overlay-nav',
|
||||||
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
|
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
|
||||||
isHorizontalLayout.value && 'layout-horizontal-nav-active',
|
isHorizontalLayout.value && 'layout-horizontal-nav-active',
|
||||||
|
isHorizontalLayout.value && isNavbarScrolled && 'layout-horizontal-nav-scrolled',
|
||||||
route.meta.layoutWrapperClasses,
|
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],
|
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||||
@@ -340,11 +342,80 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
@at-root {
|
@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,
|
html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .layout-navbar,
|
||||||
.v-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));
|
backdrop-filter: none !important;
|
||||||
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
|
background: transparent !important;
|
||||||
border-block-end-color: rgba(var(--v-theme-on-surface), 0.06);
|
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']
|
html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='vertical']
|
||||||
|
|||||||
@@ -240,24 +240,39 @@ function handleLayoutChange(layout: ThemeCustomizerLayout) {
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.theme-customizer-drawer {
|
.theme-customizer-drawer {
|
||||||
|
position: fixed !important;
|
||||||
z-index: 12000 !important;
|
z-index: 12000 !important;
|
||||||
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !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;
|
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 {
|
.v-navigation-drawer__content {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
block-size: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-customizer-drawer .v-theme--transparent,
|
.theme-customizer-drawer.v-theme--transparent,
|
||||||
.v-theme--transparent .theme-customizer-drawer,
|
.v-theme--transparent .theme-customizer-drawer,
|
||||||
html[data-theme='transparent'] .theme-customizer-drawer {
|
html[data-theme='transparent'] .theme-customizer-drawer {
|
||||||
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
|
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
|
||||||
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5)) !important;
|
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-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-color-option .theme-customizer-native-icon,
|
||||||
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-header-icon {
|
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-header-icon {
|
||||||
|
|||||||
@@ -10,12 +10,20 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
|
|||||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
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 { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||||
import { NavMenu } from '@/@layouts/types'
|
import { NavMenu } from '@/@layouts/types'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { filterMenusByPermission } from '@/utils/permission'
|
import { filterMenusByPermission } from '@/utils/permission'
|
||||||
import { onUnreadMessage } from '@/utils/badge'
|
import { onUnreadMessage } from '@/utils/badge'
|
||||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||||
@@ -34,6 +42,7 @@ const display = useDisplay()
|
|||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
@@ -133,6 +142,7 @@ interface DynamicHeaderTab {
|
|||||||
// 提供动态标签页注册和获取的方法
|
// 提供动态标签页注册和获取的方法
|
||||||
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
|
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
|
||||||
const openHorizontalNavGroup = ref<string | null>(null)
|
const openHorizontalNavGroup = ref<string | null>(null)
|
||||||
|
const pendingHorizontalTab = ref<{ path: string; tab: string } | null>(null)
|
||||||
|
|
||||||
// 提供一个方法让其他组件注册动态标签页
|
// 提供一个方法让其他组件注册动态标签页
|
||||||
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
||||||
@@ -140,6 +150,7 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
|||||||
tab.routePath = route.path
|
tab.routePath = route.path
|
||||||
// 强制更新,确保响应式系统能检测到变化
|
// 强制更新,确保响应式系统能检测到变化
|
||||||
dynamicHeaderTab.value = { ...tab }
|
dynamicHeaderTab.value = { ...tab }
|
||||||
|
applyPendingHorizontalTab()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供一个方法让其他组件取消注册动态标签页
|
// 提供一个方法让其他组件取消注册动态标签页
|
||||||
@@ -199,6 +210,20 @@ const visibleHorizontalHeaderButtons = computed(() => {
|
|||||||
return (dynamicHeaderTab.value?.appendButtons ?? []).filter(button => resolveMaybeRefValue(button.show, true) !== false)
|
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(() => {
|
onUnmounted(() => {
|
||||||
dynamicHeaderTab.value = null
|
dynamicHeaderTab.value = null
|
||||||
@@ -269,10 +294,10 @@ function handleThemeCustomizerChange(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isHorizontalNavActive(item: NavMenu) {
|
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 = normalizeMenuPath(route.path)
|
||||||
const currentPath = route.path.replace(/\/$/, '')
|
|
||||||
|
|
||||||
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
|
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
|
||||||
}
|
}
|
||||||
@@ -282,15 +307,25 @@ function isHorizontalNavGroupActive(group: { items: NavMenu[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasHorizontalDynamicTabs(item: NavMenu) {
|
function hasHorizontalDynamicTabs(item: NavMenu) {
|
||||||
return showHorizontalThemeNav.value && hasDynamicHeaderTab.value && isHorizontalNavActive(item)
|
return showHorizontalThemeNav.value && getHorizontalNavTabs(item).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
|
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
|
||||||
return dynamicHeaderTab.value?.modelValue === tab.tab
|
return dynamicHeaderTab.value?.modelValue === tab.tab
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHorizontalDynamicTabSelect(tab: DynamicHeaderTabItem) {
|
async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeaderTabItem) {
|
||||||
handleTabChange(tab.tab)
|
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
|
openHorizontalNavGroup.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +345,57 @@ function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
|
|||||||
return resolveMaybeRefValue(button.loading, false)
|
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) {
|
function handleUnreadMessage(count: number) {
|
||||||
if (superUser.value && count > 0) {
|
if (superUser.value && count > 0) {
|
||||||
@@ -439,15 +525,22 @@ onMounted(async () => {
|
|||||||
<VIcon icon="mdi-arrow-left" size="32" />
|
<VIcon icon="mdi-arrow-left" size="32" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<!-- 👉 Search Bar -->
|
<!-- 👉 Search Bar -->
|
||||||
<SearchBar />
|
<SearchBar v-if="!showHorizontalThemeNav" />
|
||||||
<!-- 👉 Spacer -->
|
<!-- 👉 Spacer -->
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<!-- 👉 Shortcuts -->
|
<div
|
||||||
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
|
class="theme-navbar-actions d-flex align-center"
|
||||||
<!-- 👉 Notification -->
|
:class="{ 'theme-navbar-actions--horizontal': showHorizontalThemeNav }"
|
||||||
<UserNofification />
|
>
|
||||||
<!-- 👉 UserProfile -->
|
<!-- 👉 Horizontal Search Icon -->
|
||||||
<UserProfile />
|
<SearchBar v-if="showHorizontalThemeNav" icon-only />
|
||||||
|
<!-- 👉 Shortcuts -->
|
||||||
|
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
|
||||||
|
<!-- 👉 Notification -->
|
||||||
|
<UserNofification />
|
||||||
|
<!-- 👉 UserProfile -->
|
||||||
|
<UserProfile />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showHorizontalThemeNav" class="theme-horizontal-nav">
|
<div v-if="showHorizontalThemeNav" class="theme-horizontal-nav">
|
||||||
<VMenu
|
<VMenu
|
||||||
@@ -479,6 +572,9 @@ onMounted(async () => {
|
|||||||
v-if="hasHorizontalDynamicTabs(item)"
|
v-if="hasHorizontalDynamicTabs(item)"
|
||||||
location="end top"
|
location="end top"
|
||||||
offset="8"
|
offset="8"
|
||||||
|
open-on-hover
|
||||||
|
:open-delay="0"
|
||||||
|
:close-delay="120"
|
||||||
:close-on-content-click="true"
|
:close-on-content-click="true"
|
||||||
>
|
>
|
||||||
<template #activator="{ props: subMenuProps }">
|
<template #activator="{ props: subMenuProps }">
|
||||||
@@ -495,13 +591,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<VList class="theme-horizontal-nav__submenu" min-width="12rem" density="comfortable">
|
<VList class="theme-horizontal-nav__submenu" min-width="12rem" density="comfortable">
|
||||||
<VListItem
|
<VListItem
|
||||||
v-for="tab in dynamicHeaderTab!.items"
|
v-for="tab in getHorizontalNavTabs(item)"
|
||||||
:key="`${item.to}-${tab.tab}`"
|
:key="`${item.to}-${tab.tab}`"
|
||||||
:active="isHorizontalDynamicTabActive(tab)"
|
:active="isHorizontalDynamicTabActive(tab)"
|
||||||
@click="handleHorizontalDynamicTabSelect(tab)"
|
@click="handleHorizontalDynamicTabSelect(item, tab)"
|
||||||
>
|
>
|
||||||
<template v-if="tab.icon" #prepend>
|
<template #prepend>
|
||||||
<VIcon :icon="tab.icon" />
|
<VIcon :icon="getHorizontalTabIcon(tab)" />
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle>{{ tab.title }}</VListItemTitle>
|
<VListItemTitle>{{ tab.title }}</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
@@ -677,6 +773,35 @@ onMounted(async () => {
|
|||||||
white-space: nowrap;
|
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 {
|
.theme-horizontal-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
|||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
iconOnly?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
iconOnly: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -23,17 +32,18 @@ function isMac() {
|
|||||||
}
|
}
|
||||||
// 计算属性:根据操作系统显示不同的按键提示
|
// 计算属性:根据操作系统显示不同的按键提示
|
||||||
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||||
|
const showIconOnly = computed(() => props.iconOnly || !display.mdAndUp.value)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 小屏:仅图标按钮 -->
|
<!-- 小屏或水平导航右侧工具区:仅显示搜索图标。 -->
|
||||||
<IconBtn v-if="!display.mdAndUp.value" @click="openSearchDialog">
|
<IconBtn v-if="showIconOnly" class="search-icon-trigger" @click="openSearchDialog">
|
||||||
<VIcon icon="mdi-magnify" />
|
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
|
||||||
<!-- 中屏及以上:胶囊搜索触发栏 -->
|
<!-- 中屏及以上:胶囊搜索触发栏 -->
|
||||||
<div v-else class="search-trigger" @click="openSearchDialog">
|
<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>
|
<span class="search-trigger-text">{{ t('common.search') }}</span>
|
||||||
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
|
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,12 +53,14 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
|||||||
.search-trigger {
|
.search-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1.5px solid rgba(var(--v-theme-primary), 0.6);
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||||
border-radius: 22px;
|
border-radius: 999px;
|
||||||
block-size: 36px;
|
background: rgba(var(--v-theme-surface), 0.44);
|
||||||
|
block-size: 44px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
padding-inline: 12px;
|
min-inline-size: 168px;
|
||||||
|
padding-inline: 18px 10px;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s ease,
|
border-color 0.2s ease,
|
||||||
background-color 0.2s ease,
|
background-color 0.2s ease,
|
||||||
@@ -57,34 +69,49 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger:hover {
|
.search-trigger:hover {
|
||||||
border-color: rgb(var(--v-theme-primary));
|
border-color: rgba(var(--v-theme-on-surface), 0.18);
|
||||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
background-color: rgba(var(--v-theme-surface), 0.62);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 6%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger-icon {
|
.search-trigger-icon {
|
||||||
flex-shrink: 0;
|
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 {
|
.search-trigger-text {
|
||||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
color: rgba(var(--v-theme-on-surface), 0.42);
|
||||||
font-size: 13.5px;
|
font-size: 1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger-kbd {
|
.search-trigger-kbd {
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
background-color: rgba(var(--v-theme-surface), 0.5);
|
||||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
color: rgba(var(--v-theme-on-surface), 0.42);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 11px;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-inline-start: 4px;
|
margin-inline-start: 4px;
|
||||||
padding-block: 3px;
|
padding-block: 6px;
|
||||||
padding-inline: 5px;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user