Files
MoviePilot-Frontend/src/layouts/components/DefaultLayout.vue

897 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
import Footer from '@/layouts/components/Footer.vue'
import UserNofification from '@/layouts/components/UserNotification.vue'
import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import QuickAccess from '@/layouts/components/QuickAccess.vue'
import HeaderTab from '@/layouts/components/HeaderTab.vue'
import AgentAssistantWidget from '@/components/AgentAssistantWidget.vue'
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
import { getNavMenus } 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, useRouter } from 'vue-router'
import {
buildUserPermissionContext,
filterItemsByPermission,
filterMenusByPermission,
hasItemPermission,
hasPermission,
type UserPermissionKey,
} from '@/utils/permission'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
import { usePWA } from '@/composables/usePWA'
import OfflinePage from '@/layouts/components/OfflinePage.vue'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import {
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
THEME_CUSTOMIZER_OPEN_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import logo from '@images/logo.svg?raw'
const display = useDisplay()
// PWA模式检测
const { appMode } = usePWA()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const themeLayout = ref(readThemeCustomizerSettings().layout)
const showThemeCustomizer = ref(false)
// 用户 Store
const userStore = useUserStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
const globalSettingsStore = useGlobalSettingsStore()
// 获取用户权限信息
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
const showAgentAssistant = computed(() => globalSettingsStore.get('AI_AGENT_ENABLE') === true)
// 开始菜单项
const startMenus = ref<NavMenu[]>([])
// 发现菜单项
const discoveryMenus = ref<NavMenu[]>([])
// 订阅菜单项
const subscribeMenus = ref<NavMenu[]>([])
// 整理菜单项
const organizeMenus = ref<NavMenu[]>([])
// 系统菜单项
const systemMenus = ref<NavMenu[]>([])
// 主题定制器的水平布局只在桌面 UI 中启用App 模式始终保留移动端导航。
const showHorizontalThemeNav = computed(() => {
return themeLayout.value === 'horizontal' && !appMode.value && !display.mdAndDown.value
})
const horizontalNavGroups = computed(() =>
[
{ title: t('menu.start'), icon: 'mdi-home-outline', items: startMenus.value },
{ title: t('menu.discovery'), icon: 'mdi-compass-outline', items: discoveryMenus.value },
{ title: t('menu.subscribe'), icon: 'mdi-rss', items: subscribeMenus.value },
{ title: t('menu.organize'), icon: 'mdi-folder-play-outline', items: organizeMenus.value },
{ title: t('menu.system'), icon: 'mdi-cog-outline', items: systemMenus.value },
].filter(group => group.items.length > 0),
)
const navbarExtraHeight = computed(() => {
const dynamicTabHeight = showDynamicHeaderTab.value ? 2.75 : 0
const horizontalNavHeight = showHorizontalThemeNav.value ? 3.25 : 0
return `${dynamicTabHeight + horizontalNavHeight}rem`
})
const mainContentPaddingTop = computed(() => {
const dynamicTabPadding = showDynamicHeaderTab.value ? 3.25 : 0
const horizontalNavPadding = showHorizontalThemeNav.value ? 3.5 : 0
return `${dynamicTabPadding + horizontalNavPadding}rem`
})
// 插件快速访问相关状态
const showPluginQuickAccess = ref(false)
// 离线状态管理
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
permission?: UserPermissionKey
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
}
interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
}
interface DynamicHeaderTab {
items: DynamicHeaderTabItem[]
modelValue: string
appendButtons?: DynamicHeaderTabButton[]
routePath?: string // 用于标识哪个路由注册的
onUpdateModelValue?: (value: string) => void // 用于通知值更新
}
// 提供动态标签页注册和获取的方法
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) => {
// 保存注册标签页的路由路径
tab.routePath = route.path
// 强制更新,确保响应式系统能检测到变化
dynamicHeaderTab.value = { ...tab }
applyPendingHorizontalTab()
}
// 提供一个方法让其他组件取消注册动态标签页
const unregisterDynamicHeaderTab = () => {
dynamicHeaderTab.value = null
}
// 标签页值更新处理
const handleTabChange = (newValue: string) => {
if (dynamicHeaderTab.value) {
dynamicHeaderTab.value.modelValue = newValue
// 通知注册的页面更新值
if (dynamicHeaderTab.value.onUpdateModelValue) {
dynamicHeaderTab.value.onUpdateModelValue(newValue)
}
}
}
// 添加全局注册方法,解决注入不可用的问题
if (typeof window !== 'undefined') {
// 确保在浏览器环境中
;(window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__ = registerDynamicHeaderTab
}
// 提供给其他组件使用
provide('registerDynamicHeaderTab', registerDynamicHeaderTab)
provide('unregisterDynamicHeaderTab', unregisterDynamicHeaderTab)
// 监听路由变化来清除动态标签页
watch(
() => route.path,
() => {
// 使用nextTick确保新页面的组件已经挂载完成
nextTick(() => {
// 如果当前标签页不属于新路由,则清除
if (dynamicHeaderTab.value && dynamicHeaderTab.value.routePath !== route.path) {
dynamicHeaderTab.value = null
}
})
},
{ immediate: false },
)
// 当前路由是否注册了动态标签页。
const hasDynamicHeaderTab = computed(() => {
return (
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
)
})
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
const visibleDynamicHeaderButtons = computed(() => {
if (!hasDynamicHeaderTab.value) return []
const visibleButtons = (dynamicHeaderTab.value?.appendButtons ?? []).filter(
button => resolveMaybeRefValue(button.show, true) !== false,
)
return filterItemsByPermission(visibleButtons, userPermissions.value)
})
const visibleHorizontalHeaderButtons = computed(() => {
if (!showHorizontalThemeNav.value) return []
return visibleDynamicHeaderButtons.value
})
// 在组件销毁时清理
onUnmounted(() => {
dynamicHeaderTab.value = null
// 清理全局方法
if (typeof window !== 'undefined') {
delete (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
}
})
// 监听Service Worker消息
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'OFFLINE_STATUS') {
if (event.data.offline) {
setAppOffline(true, t('common.serverConnectionFailed'))
} else {
setAppOffline(false)
}
}
}
// 检查是否可以使用下拉手势
const canUsePullGesture = () => {
// 检查是否在dashboard页面
const isDashboard = route.path === '/dashboard' || route.path === '/'
// 检查是否是管理员
const isAdmin = canAdmin.value
// 检查插件快速访问面板是否已显示
const quickAccessOpen = showPluginQuickAccess.value
// 检查是否离线
const offline = isOffline.value
return isDashboard && isAdmin && !quickAccessOpen && !offline
}
// 使用下拉手势 composable
const {
pullDistance,
contentTransform,
contentTransition,
showPullIndicator,
indicatorRotation,
indicatorOpacity,
indicatorTransform,
config: PULL_CONFIG,
} = usePullDownGesture({
enabled: true,
canUsePullGesture,
onTrigger: () => {
showPluginQuickAccess.value = true
},
})
// 根据分类获取菜单列表
const getMenuList = (header: string) => {
// 使用国际化菜单
const menus = getNavMenus(t)
const filteredMenus = filterMenusByPermission(menus, userPermissions.value)
return filteredMenus.filter((item: NavMenu) => item.header === header)
}
// 返回上一页
function goBack() {
history.back()
}
function handleThemeCustomizerChange(event: Event) {
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
}
function handleThemeCustomizerOpen() {
showThemeCustomizer.value = true
}
function isHorizontalNavActive(item: NavMenu) {
const targetPath = normalizeMenuPath(item.to)
if (!targetPath) return false
const currentPath = normalizeMenuPath(route.path)
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
}
function isHorizontalNavGroupActive(group: { items: NavMenu[] }) {
return group.items.some(isHorizontalNavActive)
}
function hasHorizontalDynamicTabs(item: NavMenu) {
return showHorizontalThemeNav.value && getHorizontalNavTabs(item).length > 0
}
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
return dynamicHeaderTab.value?.modelValue === 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
}
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 handleHeaderButtonClick(button: DynamicHeaderTabButton) {
if (!hasItemPermission(button, userPermissions.value)) return
button.action?.()
}
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 item.tabs ?? []
}
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 handleClosePluginQuickAccess() {
showPluginQuickAccess.value = false
}
// 点击插件后关闭
function handlePluginClick() {
showPluginQuickAccess.value = false
}
function appendPluginSidebarMenus() {
for (const { navMenu, section } of filterPluginSidebarNavEntries(
pluginSidebarNavStore.items,
t,
userPermissions.value,
)) {
switch (section) {
case 'start':
startMenus.value.push(navMenu)
break
case 'discovery':
discoveryMenus.value.push(navMenu)
break
case 'subscribe':
subscribeMenus.value.push(navMenu)
break
case 'organize':
organizeMenus.value.push(navMenu)
break
case 'system':
default:
systemMenus.value.push(navMenu)
break
}
}
}
onMounted(async () => {
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
// 获取菜单列表
startMenus.value = getMenuList(t('menu.start'))
discoveryMenus.value = getMenuList(t('menu.discovery'))
subscribeMenus.value = getMenuList(t('menu.subscribe'))
organizeMenus.value = getMenuList(t('menu.organize'))
systemMenus.value = getMenuList(t('menu.system'))
await pluginSidebarNavStore.ensureSidebarNav()
appendPluginSidebarMenus()
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
// 组件卸载时清理监听
onBeforeUnmount(() => {
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
})
})
</script>
<template>
<!-- 👉 Offline Page -->
<OfflinePage />
<!-- 👉 Pull Down Indicator -->
<div
v-if="appMode && showPullIndicator"
class="pull-indicator"
:style="{
'--pull-indicator-navbar-extra-height': navbarExtraHeight,
opacity: indicatorOpacity,
transform: indicatorTransform,
}"
>
<div
class="indicator-icon"
:style="{
transform: `scale(${
1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3
}) rotate(${indicatorRotation}deg)`,
}"
>
<VIcon
icon="mdi-gesture-swipe-down"
size="24"
:color="pullDistance >= PULL_CONFIG.TRIGGER_THRESHOLD ? 'success' : 'primary'"
/>
</div>
</div>
<VerticalNavLayout :style="{ '--navbar-tab-height': navbarExtraHeight }">
<!-- 👉 Navbar -->
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div
class="theme-navbar-row d-flex h-16 align-center mx-1"
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
>
<RouterLink v-if="showHorizontalThemeNav" :to="canAdmin ? '/dashboard' : '/apps'" class="theme-horizontal-logo">
<span class="theme-horizontal-logo__mark" v-html="logo" />
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
</RouterLink>
<!-- 👉 Vertical Nav Toggle -->
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
<VIcon icon="mdi-menu" />
</IconBtn>
<!-- 👉 Back Button -->
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
<VIcon icon="mdi-arrow-left" size="32" />
</IconBtn>
<!-- 👉 Search Bar -->
<SearchBar v-if="!showHorizontalThemeNav" />
<!-- 👉 Spacer -->
<VSpacer />
<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="canAdmin" />
<!-- 👉 Notification -->
<UserNofification />
<!-- 👉 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"
open-on-hover
:open-delay="0"
:close-delay="120"
: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 getHorizontalNavTabs(item)"
:key="`${item.to}-${tab.tab}`"
:active="isHorizontalDynamicTabActive(tab)"
@click="handleHorizontalDynamicTabSelect(item, tab)"
>
<template #prepend>
<VIcon :icon="getHorizontalTabIcon(tab)" />
</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="handleHeaderButtonClick(button)"
/>
</div>
</div>
</template>
<template #vertical-nav-content>
<VerticalNavLink v-for="item in startMenus" :item="item" />
<!-- 👉 发现 -->
<VerticalNavSectionTitle
v-if="discoveryMenus.length > 0"
:item="{
heading: t('menu.discovery'),
}"
/>
<VerticalNavLink v-for="item in discoveryMenus" :item="item" />
<!-- 👉 订阅 -->
<VerticalNavSectionTitle
v-if="subscribeMenus.length > 0"
:item="{
heading: t('menu.subscribe'),
}"
/>
<VerticalNavLink v-for="item in subscribeMenus" :item="item" />
<!-- 👉 整理 -->
<VerticalNavSectionTitle
v-if="organizeMenus.length > 0"
:item="{
heading: t('menu.organize'),
}"
/>
<VerticalNavLink v-for="item in organizeMenus" :item="item" />
<!-- 👉 系统 -->
<VerticalNavSectionTitle
v-if="systemMenus.length > 0"
:item="{
heading: t('menu.system'),
}"
/>
<VerticalNavLink v-for="item in systemMenus" :item="item" />
</template>
<template #after-vertical-nav-items />
<!-- 👉 Dynamic Header Tab -->
<template #dynamic-header-tab>
<div v-if="showDynamicHeaderTab">
<HeaderTab
:items="dynamicHeaderTab!.items"
:model-value="dynamicHeaderTab!.modelValue"
@update:model-value="handleTabChange"
>
<template #append>
<template v-for="button in visibleDynamicHeaderButtons" :key="button.icon">
<VBtn
: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="handleHeaderButtonClick(button)"
/>
</template>
</template>
</HeaderTab>
</div>
</template>
<!-- 👉 下拉跟随动画 -->
<div
class="main-content-wrapper"
:style="{
transform: contentTransform,
transition: contentTransition,
paddingTop: mainContentPaddingTop,
}"
>
<slot />
</div>
<!-- 👉 Footer -->
<template #footer>
<Footer :show-nav="!showPluginQuickAccess" />
</template>
</VerticalNavLayout>
<!-- 👉 Plugin Quick Access -->
<QuickAccess
v-if="appMode"
:visible="showPluginQuickAccess"
:pull-distance="pullDistance"
@close="handleClosePluginQuickAccess"
@plugin-click="handlePluginClick"
/>
<!-- 👉 Theme Customizer -->
<ThemeCustomizer v-if="showThemeCustomizer" @close="showThemeCustomizer = false" />
<!-- 👉 Agent Assistant -->
<AgentAssistantWidget v-if="showAgentAssistant" />
</template>
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.main-content-wrapper {
backface-visibility: hidden;
block-size: 100%;
inline-size: 100%;
transform: translateZ(0);
will-change: transform;
}
.theme-navbar-row--horizontal {
gap: 1rem;
margin-inline: 0 !important;
}
:deep(.layout-dynamic-header-tab) {
padding-block-end: 0.25rem;
}
.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-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;
align-items: center;
block-size: 3.25rem;
gap: 0.25rem;
overflow-x: auto;
padding-block: 0.25rem 0.5rem;
padding-inline: 0.5rem;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.theme-horizontal-nav__item {
flex: 0 0 auto;
letter-spacing: 0;
}
.theme-horizontal-nav__menu,
.theme-horizontal-nav__submenu {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.theme-horizontal-nav__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
margin-inline-start: auto;
}
.pull-indicator {
position: fixed;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
backdrop-filter: blur(20px);
background: rgba(var(--v-theme-surface), 0.3);
box-shadow:
0 1px 2px rgba(0, 0, 0, 10%),
0 1px 3px rgba(0, 0, 0, 6%);
inset-block-start: calc(
env(safe-area-inset-top, 0px) + 4rem + var(--pull-indicator-navbar-extra-height, 0rem) + 0.75rem
);
inset-inline-start: 50%;
pointer-events: none;
transform: translate3d(-50%, 0, 0);
transition:
opacity 0.2s ease,
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
will-change: opacity, transform;
}
.indicator-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--v-theme-primary), 0.08);
block-size: 40px;
inline-size: 40px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 透明主题适配 */
html[class*='transparent'] .pull-indicator,
html[class*='mica'] .pull-indicator,
html[class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 20%);
background: rgba(255, 255, 255, 95%);
box-shadow:
0 8px 32px rgba(0, 0, 0, 12%),
0 4px 16px rgba(0, 0, 0, 8%);
}
html[class*='transparent'] .indicator-icon,
html[class*='mica'] .indicator-icon,
html[class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.12);
}
html[data-theme='dark'][class*='transparent'] .pull-indicator,
html[data-theme='dark'][class*='mica'] .pull-indicator,
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 10%);
background: rgba(18, 18, 18, 95%);
box-shadow:
0 8px 32px rgba(0, 0, 0, 30%),
0 4px 16px rgba(0, 0, 0, 20%);
}
html[data-theme='dark'][class*='transparent'] .indicator-icon,
html[data-theme='dark'][class*='mica'] .indicator-icon,
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.15);
}
</style>