feat: enhance page transition animations and overlay effects

This commit is contained in:
jxxghp
2026-06-12 10:42:41 +08:00
parent 62c9a10377
commit 4f328add1b
5 changed files with 251 additions and 9 deletions

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { Transition } from 'vue'
import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue'
import {
@@ -110,9 +109,7 @@ export default defineComponent({
const main = h(
'main',
{ class: 'layout-page-content' },
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
h('section', { class: 'page-content-container' }, slots.default?.()),
),
h('section', { class: 'page-content-container' }, slots.default?.()),
)
// 👉 根据路由 meta 决定 footer 高度

View File

@@ -1,6 +1,19 @@
<script lang="ts" setup>
const route = useRoute()
// 空白布局用于登录、初始化与 404复用全局页面动效保持切换手感一致。
const routeTransitionKey = computed(() => route.fullPath)
</script>
<template>
<div class="layout-wrapper layout-blank">
<RouterView />
<RouterView v-slot="{ Component }">
<transition name="mp-page" mode="out-in" appear>
<div :key="routeTransitionKey" class="mp-page-route">
<component :is="Component" />
</div>
</transition>
</RouterView>
</div>
</template>

View File

@@ -5,15 +5,55 @@ const route = useRoute()
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
// 页面过渡按实际页面身份触发keep-alive 页面避免 query 变化时反复入场。
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
const isPageEntering = ref(false)
let pageMotionTimer: number | null = null
let pageMotionFrame: number | null = null
// 使用稳定容器触发轻量入场动画,避免重建 keep-alive 导致页面缓存失效。
function playPageEnterMotion() {
if (pageMotionTimer) {
window.clearTimeout(pageMotionTimer)
pageMotionTimer = null
}
if (pageMotionFrame) {
window.cancelAnimationFrame(pageMotionFrame)
pageMotionFrame = null
}
isPageEntering.value = false
pageMotionFrame = window.requestAnimationFrame(() => {
isPageEntering.value = true
pageMotionFrame = null
pageMotionTimer = window.setTimeout(() => {
isPageEntering.value = false
pageMotionTimer = null
}, 220)
})
}
watch(routeTransitionKey, playPageEnterMotion, { flush: 'post' })
onMounted(playPageEnterMotion)
onBeforeUnmount(() => {
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
})
</script>
<template>
<DefaultLayout>
<router-view v-slot="{ Component }">
<keep-alive :max="24">
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
</keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
<div class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
<keep-alive :max="24">
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
</keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
</div>
</router-view>
</DefaultLayout>
</template>

View File

@@ -34,16 +34,19 @@ export default {
},
VMenu: {
elevation: 0,
transition: 'mp-popover-transition',
},
VChip: {
elevation: 0,
},
VBottomSheet: {
elevation: 0,
transition: 'mp-bottom-sheet-transition',
},
VDialog: {
elevation: 0,
rounded: 'lg',
transition: 'mp-dialog-transition',
},
VExpansionPanels: {
elevation: 0,
@@ -68,6 +71,7 @@ export default {
VTooltip: {
// set v-tooltip default location to top
location: 'top',
transition: 'mp-popover-transition',
},
VCheckboxBtn: {
color: 'primary',

View File

@@ -50,6 +50,10 @@ html {
--app-overlay-shadow: none;
--app-surface-shadow: none;
--app-surface-hover-shadow: none;
--mp-motion-duration-page: 180ms;
--mp-motion-duration-overlay: 160ms;
--mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
--mp-motion-ease-exit: cubic-bezier(0.4, 0, 1, 1);
}
html[data-theme-skin='bordered'] {
@@ -156,6 +160,190 @@ html[data-theme-shadow='high'] {
box-shadow: var(--app-surface-shadow) !important;
}
// 全局页面与 overlay 动效:短距离、轻缩放,保持快速但不生硬。
.mp-page-route {
inline-size: 100%;
min-block-size: 100%;
}
.mp-page-route--entering {
animation: mp-page-route-enter var(--mp-motion-duration-page) var(--mp-motion-ease-standard) both;
will-change: opacity, transform;
}
@keyframes mp-page-route-enter {
from {
filter: blur(1px);
opacity: 0;
transform: translate3d(0, 0.5rem, 0) scale(0.992);
}
to {
filter: blur(0);
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
.mp-page-enter-active,
.mp-page-leave-active {
backface-visibility: hidden;
transform-origin: center top;
transition:
opacity var(--mp-motion-duration-page) var(--mp-motion-ease-standard),
transform var(--mp-motion-duration-page) var(--mp-motion-ease-standard),
filter var(--mp-motion-duration-page) var(--mp-motion-ease-standard);
will-change: opacity, transform;
}
.mp-page-leave-active {
transition-duration: 120ms;
transition-timing-function: var(--mp-motion-ease-exit);
}
.mp-page-enter-from {
filter: blur(1px);
opacity: 0;
transform: translate3d(0, 0.5rem, 0) scale(0.992);
}
.mp-page-leave-to {
opacity: 0;
transform: translate3d(0, -0.25rem, 0) scale(0.998);
}
.mp-dialog-transition-enter-active,
.mp-dialog-transition-leave-active,
.dialog-transition-enter-active,
.dialog-transition-leave-active,
.v-overlay__content.mp-dialog-transition-enter-active,
.v-overlay__content.mp-dialog-transition-leave-active,
.v-overlay__content.dialog-transition-enter-active,
.v-overlay__content.dialog-transition-leave-active {
transition:
opacity var(--mp-motion-duration-overlay) var(--mp-motion-ease-standard),
transform var(--mp-motion-duration-overlay) var(--mp-motion-ease-standard),
filter var(--mp-motion-duration-overlay) var(--mp-motion-ease-standard);
will-change: opacity, transform;
}
.mp-dialog-transition-leave-active,
.dialog-transition-leave-active {
transition-duration: 120ms;
transition-timing-function: var(--mp-motion-ease-exit);
}
.mp-dialog-transition-enter-from,
.mp-dialog-transition-leave-to,
.dialog-transition-enter-from,
.dialog-transition-leave-to {
filter: blur(0.5px);
opacity: 0;
transform: translate3d(0, 0.625rem, 0) scale(0.985);
}
.mp-popover-transition-enter-active,
.mp-popover-transition-leave-active,
.v-overlay__content.mp-popover-transition-enter-active,
.v-overlay__content.mp-popover-transition-leave-active {
transition:
opacity 120ms var(--mp-motion-ease-standard),
transform 120ms var(--mp-motion-ease-standard);
will-change: opacity, transform;
}
.mp-popover-transition-leave-active {
transition-duration: 90ms;
transition-timing-function: var(--mp-motion-ease-exit);
}
.mp-popover-transition-enter-from,
.mp-popover-transition-leave-to {
opacity: 0;
transform: translate3d(0, -0.25rem, 0) scale(0.98);
}
.mp-bottom-sheet-transition-enter-active,
.mp-bottom-sheet-transition-leave-active,
.dialog-bottom-transition-enter-active,
.dialog-bottom-transition-leave-active,
.v-overlay__content.mp-bottom-sheet-transition-enter-active,
.v-overlay__content.mp-bottom-sheet-transition-leave-active,
.v-overlay__content.dialog-bottom-transition-enter-active,
.v-overlay__content.dialog-bottom-transition-leave-active {
transition:
opacity var(--mp-motion-duration-overlay) var(--mp-motion-ease-standard),
transform var(--mp-motion-duration-overlay) var(--mp-motion-ease-standard);
will-change: opacity, transform;
}
.mp-bottom-sheet-transition-leave-active,
.dialog-bottom-transition-leave-active {
transition-duration: 120ms;
transition-timing-function: var(--mp-motion-ease-exit);
}
.mp-bottom-sheet-transition-enter-from,
.mp-bottom-sheet-transition-leave-to,
.dialog-bottom-transition-enter-from,
.dialog-bottom-transition-leave-to {
opacity: 0;
transform: translate3d(0, 1.25rem, 0);
}
.v-dialog > .v-overlay__content {
transform-origin: center;
}
.v-menu > .v-overlay__content,
.v-tooltip > .v-overlay__content {
transform-origin: top center;
}
.v-overlay__scrim,
.v-navigation-drawer__scrim {
transition:
background-color var(--mp-motion-duration-overlay) var(--mp-motion-ease-standard),
opacity var(--mp-motion-duration-overlay) var(--mp-motion-ease-standard);
}
@media (prefers-reduced-motion: reduce) {
.mp-page-route--entering {
animation-duration: 1ms !important;
}
.mp-page-enter-active,
.mp-page-leave-active,
.mp-dialog-transition-enter-active,
.mp-dialog-transition-leave-active,
.dialog-transition-enter-active,
.dialog-transition-leave-active,
.mp-popover-transition-enter-active,
.mp-popover-transition-leave-active,
.mp-bottom-sheet-transition-enter-active,
.mp-bottom-sheet-transition-leave-active,
.dialog-bottom-transition-enter-active,
.dialog-bottom-transition-leave-active {
transition-duration: 1ms !important;
}
.mp-page-enter-from,
.mp-page-leave-to,
.mp-dialog-transition-enter-from,
.mp-dialog-transition-leave-to,
.dialog-transition-enter-from,
.dialog-transition-leave-to,
.mp-popover-transition-enter-from,
.mp-popover-transition-leave-to,
.mp-bottom-sheet-transition-enter-from,
.mp-bottom-sheet-transition-leave-to,
.dialog-bottom-transition-enter-from,
.dialog-bottom-transition-leave-to {
filter: none !important;
transform: none !important;
}
}
// 底部导航卡片原本就是胶囊形态,不参与主题圆角切换。
.footer-nav-card.v-card {
--app-surface-radius: 9999px;