mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
perf: 优化导航栏动画流畅度
This commit is contained in:
@@ -15,7 +15,6 @@ defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
@@ -171,51 +170,57 @@ const showDynamicButton = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<Teleport v-if="appMode && showNav" to="body">
|
<Teleport v-if="appMode && showNav" to="body">
|
||||||
<div class="footer-nav-container">
|
<div class="footer-nav-container">
|
||||||
<VCard elevation="3" class="footer-nav-card border" rounded="pill" :class="{ 'shift-left': showDynamicButton }">
|
<TransitionGroup name="footer-nav" tag="div" class="footer-nav-group">
|
||||||
<VCardText class="footer-card-content">
|
<VCard key="main-nav" elevation="3" class="footer-nav-card border" rounded="pill">
|
||||||
<!-- 添加指示器 -->
|
<VCardText class="footer-card-content">
|
||||||
<div ref="indicator" class="nav-indicator"></div>
|
<!-- 添加指示器 -->
|
||||||
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
|
<div ref="indicator" class="nav-indicator"></div>
|
||||||
<!-- 遍历底部菜单项 -->
|
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
|
||||||
<VBtn
|
<!-- 遍历底部菜单项 -->
|
||||||
v-for="menu in footerMenus"
|
<VBtn
|
||||||
:key="menu.to"
|
v-for="menu in footerMenus"
|
||||||
:to="menu.to"
|
:key="menu.to"
|
||||||
:variant="currentMenu === menu.to ? 'text' : 'plain'"
|
:to="menu.to"
|
||||||
color="primary"
|
:variant="currentMenu === menu.to ? 'text' : 'plain'"
|
||||||
:ripple="false"
|
color="primary"
|
||||||
class="footer-nav-btn"
|
:ripple="false"
|
||||||
rounded="pill"
|
class="footer-nav-btn"
|
||||||
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
|
rounded="pill"
|
||||||
:value="menu.to"
|
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
|
||||||
>
|
:value="menu.to"
|
||||||
<div class="btn-content">
|
>
|
||||||
<VIcon :icon="menu.icon" size="32"></VIcon>
|
<div class="btn-content">
|
||||||
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
|
<VIcon :icon="menu.icon" size="32"></VIcon>
|
||||||
</div>
|
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
|
||||||
</VBtn>
|
</div>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
<!-- 更多按钮 -->
|
<!-- 更多按钮 -->
|
||||||
<VBtn
|
<VBtn
|
||||||
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
|
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
|
||||||
color="primary"
|
color="primary"
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
to="/apps"
|
to="/apps"
|
||||||
rounded="pill"
|
rounded="pill"
|
||||||
class="footer-nav-btn"
|
class="footer-nav-btn"
|
||||||
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
|
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
|
||||||
value="/apps"
|
value="/apps"
|
||||||
>
|
>
|
||||||
<div class="btn-content">
|
<div class="btn-content">
|
||||||
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
|
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
|
||||||
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
|
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VBtnToggle>
|
</VBtnToggle>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
<Transition name="fade-slide">
|
<VCard
|
||||||
<VCard v-if="showDynamicButton" elevation="3" class="footer-nav-card dynamic-btn-card border" rounded="pill">
|
v-if="showDynamicButton"
|
||||||
|
key="dynamic-btn"
|
||||||
|
elevation="3"
|
||||||
|
class="footer-nav-card dynamic-btn-card border"
|
||||||
|
rounded="pill"
|
||||||
|
>
|
||||||
<VCardText class="footer-card-content">
|
<VCardText class="footer-card-content">
|
||||||
<!-- 各页面的动态按钮 -->
|
<!-- 各页面的动态按钮 -->
|
||||||
<VBtn
|
<VBtn
|
||||||
@@ -230,7 +235,7 @@ const showDynamicButton = computed(() => {
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</Transition>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
@@ -246,6 +251,12 @@ const showDynamicButton = computed(() => {
|
|||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));
|
padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
// 按钮卡片之间的间距
|
// 按钮卡片之间的间距
|
||||||
> .v-card + .v-card {
|
> .v-card + .v-card {
|
||||||
@@ -260,6 +271,7 @@ const showDynamicButton = computed(() => {
|
|||||||
background-color: rgba(var(--v-theme-surface), 0.6);
|
background-color: rgba(var(--v-theme-surface), 0.6);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
will-change: transform, max-width, opacity;
|
||||||
|
|
||||||
// 透明主题下的特殊样式
|
// 透明主题下的特殊样式
|
||||||
.v-theme--transparent & {
|
.v-theme--transparent & {
|
||||||
@@ -267,10 +279,6 @@ const showDynamicButton = computed(() => {
|
|||||||
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
|
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
&.shift-left {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-btn-toggle {
|
.v-btn-toggle {
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
min-block-size: 56px;
|
min-block-size: 56px;
|
||||||
@@ -328,6 +336,7 @@ const showDynamicButton = computed(() => {
|
|||||||
block-size: auto;
|
block-size: auto;
|
||||||
inline-size: auto;
|
inline-size: auto;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
|
max-width: 60px;
|
||||||
|
|
||||||
.footer-card-content {
|
.footer-card-content {
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
@@ -349,23 +358,25 @@ const showDynamicButton = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 淡入滑动动画
|
// 底部导航动画
|
||||||
.fade-slide-enter-active {
|
.footer-nav-enter-active,
|
||||||
|
.footer-nav-leave-active {
|
||||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-leave-active {
|
.footer-nav-enter-from,
|
||||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
.footer-nav-leave-to {
|
||||||
}
|
|
||||||
|
|
||||||
.fade-slide-enter-from {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
max-width: 0 !important;
|
||||||
|
margin-inline-start: 0 !important;
|
||||||
|
border-width: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
transform: translateX(20px);
|
transform: translateX(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-leave-to {
|
.footer-nav-move {
|
||||||
opacity: 0;
|
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
|
|||||||
+23
-2
@@ -231,12 +231,23 @@ registerHeaderTab({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 页面是否准备就绪
|
||||||
|
const isReady = ref(false)
|
||||||
|
|
||||||
|
// 定时器
|
||||||
|
let timer: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
await loadConfig()
|
await loadConfig()
|
||||||
initializeColors()
|
initializeColors()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 延迟渲染内容,避免阻塞页面切换动画
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
isReady.value = true
|
||||||
|
}, 400)
|
||||||
|
|
||||||
await loadExtraRecommendSources()
|
await loadExtraRecommendSources()
|
||||||
// 为新增的数据源也生成颜色
|
// 为新增的数据源也生成颜色
|
||||||
extraRecommendSources.value.forEach(source => {
|
extraRecommendSources.value.forEach(source => {
|
||||||
@@ -246,6 +257,10 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
})
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
await loadExtraRecommendSources()
|
await loadExtraRecommendSources()
|
||||||
})
|
})
|
||||||
@@ -256,10 +271,16 @@ onActivated(async () => {
|
|||||||
<!-- 滚动内容区域 -->
|
<!-- 滚动内容区域 -->
|
||||||
<div class="recommend-content">
|
<div class="recommend-content">
|
||||||
<TransitionGroup name="fade">
|
<TransitionGroup name="fade">
|
||||||
<MediaCardSlideView v-for="item in filteredViews" :key="item.title" v-bind="item" class="content-group" />
|
<MediaCardSlideView
|
||||||
|
v-for="item in filteredViews"
|
||||||
|
:key="item.title"
|
||||||
|
v-bind="item"
|
||||||
|
:ready="isReady"
|
||||||
|
class="content-group"
|
||||||
|
/>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
<div v-if="filteredViews.length === 0" class="empty-category">
|
<div v-if="isReady && filteredViews.length === 0" class="empty-category">
|
||||||
<VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />
|
<VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />
|
||||||
<p class="empty-text">{{ t('recommend.noCategoryContent') }}</p>
|
<p class="empty-text">{{ t('recommend.noCategoryContent') }}</p>
|
||||||
<VBtn color="primary" variant="tonal" size="small" @click="dialog = true">
|
<VBtn color="primary" variant="tonal" size="small" @click="dialog = true">
|
||||||
|
|||||||
@@ -74,17 +74,17 @@ html.v-overlay-scroll-blocked body {
|
|||||||
// 路由过渡动画
|
// 路由过渡动画
|
||||||
.fade-slide-leave-active,
|
.fade-slide-leave-active,
|
||||||
.fade-slide-enter-active {
|
.fade-slide-enter-active {
|
||||||
transition: all 0.6s;
|
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-enter-from {
|
.fade-slide-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-45px);
|
transform: translateX(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-leave-to {
|
.fade-slide-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(45px);
|
transform: translateX(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 网格布局样式
|
// 网格布局样式
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { MediaInfo } from '@/api/types'
|
|||||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||||
import SlideView from '@/components/slide/SlideView.vue'
|
import SlideView from '@/components/slide/SlideView.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useIntersectionObserver, until } from '@vueuse/core'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -12,6 +13,10 @@ const props = defineProps({
|
|||||||
apipath: String,
|
apipath: String,
|
||||||
linkurl: String,
|
linkurl: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
ready: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 提供给子组件的属性
|
// 提供给子组件的属性
|
||||||
@@ -19,38 +24,71 @@ provide('rankingPropsKey', reactive({ ...props }))
|
|||||||
|
|
||||||
// 组件加载完成
|
// 组件加载完成
|
||||||
const componentLoaded = ref(false)
|
const componentLoaded = ref(false)
|
||||||
|
// 是否已尝试加载
|
||||||
|
const hasTriedLoading = ref(false)
|
||||||
|
|
||||||
// 数据列表
|
// 数据列表
|
||||||
const dataList = ref<MediaInfo[]>([])
|
const dataList = ref<MediaInfo[]>([])
|
||||||
|
|
||||||
|
// 容器引用
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 获取订阅列表数据
|
// 获取订阅列表数据
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
if (!props.apipath) return
|
if (!props.apipath) return
|
||||||
dataList.value = await api.get(props.apipath)
|
dataList.value = await api.get(props.apipath)
|
||||||
if (dataList.value.length > 0) componentLoaded.value = true
|
if (dataList.value.length > 0) {
|
||||||
|
// 数据获取后,等待 ready 信号再渲染,避免阻塞动画
|
||||||
|
await until(() => props.ready).toBe(true)
|
||||||
|
}
|
||||||
|
componentLoaded.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
componentLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
hasTriedLoading.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载时获取数据
|
// 使用 IntersectionObserver 实现懒加载
|
||||||
onMounted(() => {
|
const { stop } = useIntersectionObserver(
|
||||||
fetchData()
|
containerRef,
|
||||||
})
|
([{ isIntersecting }]) => {
|
||||||
|
if (isIntersecting) {
|
||||||
|
fetchData()
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '300px', // 提前加载距离
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
if (dataList.value.length == 0) {
|
if (dataList.value.length == 0 && hasTriedLoading.value) {
|
||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SlideView v-if="componentLoaded">
|
<div ref="containerRef">
|
||||||
<template #content>
|
<SlideView v-if="componentLoaded">
|
||||||
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
|
<template #content>
|
||||||
<MediaCard :media="data" width="9rem" />
|
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
|
||||||
|
<MediaCard :media="data" width="9rem" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</SlideView>
|
||||||
</SlideView>
|
<SlideView v-else-if="!componentLoaded">
|
||||||
|
<template #content>
|
||||||
|
<div v-for="i in 10" :key="i" style="width: 9rem">
|
||||||
|
<VCard class="outline-none overflow-hidden">
|
||||||
|
<div style="padding-bottom: 150%"></div>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SlideView>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user