diff --git a/src/layouts/components/Footer.vue b/src/layouts/components/Footer.vue index 7d54be75..dc3d5d34 100644 --- a/src/layouts/components/Footer.vue +++ b/src/layouts/components/Footer.vue @@ -15,7 +15,6 @@ defineProps({ }, }) - const display = useDisplay() // PWA模式检测 const { appMode } = usePWA() @@ -171,51 +170,57 @@ const showDynamicButton = computed(() => { @@ -246,6 +251,12 @@ const showDynamicButton = computed(() => { inset-inline: 0; padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px)); pointer-events: none; +} + +.footer-nav-group { + display: flex; + align-items: center; + justify-content: center; // 按钮卡片之间的间距 > .v-card + .v-card { @@ -260,6 +271,7 @@ const showDynamicButton = computed(() => { background-color: rgba(var(--v-theme-surface), 0.6); pointer-events: auto; transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1); + will-change: transform, max-width, opacity; // 透明主题下的特殊样式 .v-theme--transparent & { @@ -267,10 +279,6 @@ const showDynamicButton = computed(() => { background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5)); } - &.shift-left { - transform: translateX(0); - } - .v-btn-toggle { block-size: auto; min-block-size: 56px; @@ -328,6 +336,7 @@ const showDynamicButton = computed(() => { block-size: auto; inline-size: auto; min-block-size: 0; + max-width: 60px; .footer-card-content { 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); + overflow: hidden; } -.fade-slide-leave-active { - transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1); -} - -.fade-slide-enter-from { +.footer-nav-enter-from, +.footer-nav-leave-to { opacity: 0; + max-width: 0 !important; + margin-inline-start: 0 !important; + border-width: 0 !important; + padding: 0 !important; transform: translateX(20px); } -.fade-slide-leave-to { - opacity: 0; - transform: translateX(20px); +.footer-nav-move { + transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1); } @keyframes fade-in { diff --git a/src/pages/recommend.vue b/src/pages/recommend.vue index 505c50ed..45e04032 100644 --- a/src/pages/recommend.vue +++ b/src/pages/recommend.vue @@ -231,12 +231,23 @@ registerHeaderTab({ ], }) +// 页面是否准备就绪 +const isReady = ref(false) + +// 定时器 +let timer: ReturnType + onBeforeMount(async () => { await loadConfig() initializeColors() }) onMounted(async () => { + // 延迟渲染内容,避免阻塞页面切换动画 + timer = setTimeout(() => { + isReady.value = true + }, 400) + await loadExtraRecommendSources() // 为新增的数据源也生成颜色 extraRecommendSources.value.forEach(source => { @@ -246,6 +257,10 @@ onMounted(async () => { }) }) +onUnmounted(() => { + if (timer) clearTimeout(timer) +}) + onActivated(async () => { await loadExtraRecommendSources() }) @@ -256,10 +271,16 @@ onActivated(async () => {
- + -
+

{{ t('recommend.noCategoryContent') }}

diff --git a/src/styles/common.scss b/src/styles/common.scss index ccee94bf..e3f2769b 100644 --- a/src/styles/common.scss +++ b/src/styles/common.scss @@ -74,17 +74,17 @@ html.v-overlay-scroll-blocked body { // 路由过渡动画 .fade-slide-leave-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 { opacity: 0; - transform: translateY(-45px); + transform: translateX(20px); } .fade-slide-leave-to { opacity: 0; - transform: translateY(45px); + transform: translateX(20px); } // 网格布局样式 diff --git a/src/views/discover/MediaCardSlideView.vue b/src/views/discover/MediaCardSlideView.vue index f381d53f..16e61500 100644 --- a/src/views/discover/MediaCardSlideView.vue +++ b/src/views/discover/MediaCardSlideView.vue @@ -4,6 +4,7 @@ import type { MediaInfo } from '@/api/types' import MediaCard from '@/components/cards/MediaCard.vue' import SlideView from '@/components/slide/SlideView.vue' import { useI18n } from 'vue-i18n' +import { useIntersectionObserver, until } from '@vueuse/core' const { t } = useI18n() @@ -12,6 +13,10 @@ const props = defineProps({ apipath: String, linkurl: String, title: String, + ready: { + type: Boolean, + default: true, + }, }) // 提供给子组件的属性 @@ -19,38 +24,71 @@ provide('rankingPropsKey', reactive({ ...props })) // 组件加载完成 const componentLoaded = ref(false) +// 是否已尝试加载 +const hasTriedLoading = ref(false) // 数据列表 const dataList = ref([]) +// 容器引用 +const containerRef = ref(null) + // 获取订阅列表数据 async function fetchData() { try { if (!props.apipath) return 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) { console.error(error) + componentLoaded.value = true + } finally { + hasTriedLoading.value = true } } -// 加载时获取数据 -onMounted(() => { - fetchData() -}) +// 使用 IntersectionObserver 实现懒加载 +const { stop } = useIntersectionObserver( + containerRef, + ([{ isIntersecting }]) => { + if (isIntersecting) { + fetchData() + stop() + } + }, + { + rootMargin: '300px', // 提前加载距离 + }, +) + onActivated(() => { - if (dataList.value.length == 0) { + if (dataList.value.length == 0 && hasTriedLoading.value) { fetchData() } })