From 4f328add1b7ce96bbf92cb5d846a34a5b95844bd Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 12 Jun 2026 10:42:41 +0800 Subject: [PATCH] feat: enhance page transition animations and overlay effects --- src/@layouts/components/VerticalNavLayout.vue | 5 +- src/layouts/blank.vue | 15 +- src/layouts/default.vue | 48 ++++- src/plugins/vuetify/defaults.ts | 4 + src/styles/common.scss | 188 ++++++++++++++++++ 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index 2cc24ad6..0d412ac6 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -1,5 +1,4 @@ + diff --git a/src/layouts/default.vue b/src/layouts/default.vue index 702ae04b..e24c9d01 100644 --- a/src/layouts/default.vue +++ b/src/layouts/default.vue @@ -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) +}) diff --git a/src/plugins/vuetify/defaults.ts b/src/plugins/vuetify/defaults.ts index 3ad423a9..e958bb62 100644 --- a/src/plugins/vuetify/defaults.ts +++ b/src/plugins/vuetify/defaults.ts @@ -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', diff --git a/src/styles/common.scss b/src/styles/common.scss index ea70839d..fa5835b5 100644 --- a/src/styles/common.scss +++ b/src/styles/common.scss @@ -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;