Files
love-key-web/src/components/FullPage.vue
2026-02-27 15:37:57 +08:00

182 lines
3.9 KiB
Vue

<template>
<div class="fullpage-container" @wheel.prevent="handleWheel" @touchstart="handleTouchStart"
@touchend="handleTouchEnd">
<div class="fullpage-wrapper" :style="wrapperStyle">
<slot />
</div>
<div class="fullpage-dots">
<span v-for="i in pageCount" :key="i" class="dot" :class="{ active: currentIndex === i - 1 }"
@click="goTo(i - 1)">
<span class="dot-inner" />
</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, useSlots, watch, nextTick } from 'vue'
const slots = useSlots()
const pageCount = computed(() => {
const defaultSlot = slots.default?.()
return defaultSlot ? defaultSlot.length : 0
})
const currentIndex = ref(0)
const isAnimating = ref(false)
const touchStartY = ref(0)
const direction = ref('down')
const wrapperStyle = computed(() => ({
transform: `translateY(-${currentIndex.value * 100}vh)`,
transition: isAnimating.value ? 'transform 0.9s cubic-bezier(0.76, 0, 0.24, 1)' : 'none',
}))
function goTo(index) {
if (isAnimating.value || index === currentIndex.value) return
if (index < 0 || index >= pageCount.value) return
direction.value = index > currentIndex.value ? 'down' : 'up'
isAnimating.value = true
currentIndex.value = index
triggerPageAnimation(index)
setTimeout(() => { isAnimating.value = false }, 900)
}
function triggerPageAnimation(index) {
nextTick(() => {
const pages = document.querySelectorAll('.fullpage-wrapper > *')
const page = pages[index]
if (!page) return
page.classList.remove('page-enter')
void page.offsetWidth
page.classList.add('page-enter')
})
}
function handleWheel(e) {
if (isAnimating.value) return
if (e.deltaY > 0) goTo(currentIndex.value + 1)
else if (e.deltaY < 0) goTo(currentIndex.value - 1)
}
function handleTouchStart(e) {
touchStartY.value = e.touches[0].clientY
}
function handleTouchEnd(e) {
const deltaY = touchStartY.value - e.changedTouches[0].clientY
if (Math.abs(deltaY) > 50) {
if (deltaY > 0) goTo(currentIndex.value + 1)
else goTo(currentIndex.value - 1)
}
}
defineExpose({ currentIndex, goTo })
</script>
<style scoped>
.fullpage-container {
width: 100%;
height: 100vh;
overflow: hidden;
position: relative;
}
.fullpage-wrapper {
width: 100%;
will-change: transform;
}
/* 圆点导航升级 */
.fullpage-dots {
position: fixed;
right: 28px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 16px;
z-index: 100;
}
.dot {
width: 12px;
height: 12px;
position: relative;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.dot::before {
content: '';
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1.5px solid rgba(0, 0, 0, 0.2);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dot-inner {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.25);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
display: block;
}
.dot.active::before {
border-color: #00BFA5;
width: 20px;
height: 20px;
box-shadow: 0 0 0 3px rgba(0, 191, 165, 0.15);
}
.dot.active .dot-inner {
width: 6px;
height: 6px;
background: #00BFA5;
box-shadow: 0 0 8px rgba(0, 191, 165, 0.6);
}
/* 页面入场动画 - 仅针对非绝对定位元素 */
:global(.page-enter .text-side),
:global(.page-enter .image-side),
:global(.page-enter .content-wrapper) {
animation: fadeSlideUp 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
}
:global(.page-enter .image-side) {
animation-delay: 0.1s;
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.fullpage-dots {
right: unset;
top: unset;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
flex-direction: row;
}
.dot.active::before {
width: 16px;
height: 16px;
}
}
</style>