@@ -1,5 +1,5 @@
< template >
< div class = "wrapper relative w-full h-[100vh] bg-[#Fdfbf7] overflow-hidden text-[#2c2c2c]" >
< div class = "wrapper relative w-full min- h-[100vh] bg-[#Fdfbf7] overflow-x- hidden text-[#2c2c2c]" >
<!-- 入场揭幕帘幕 ( 请柬封面 ) -- >
< div v-show = "showIntro" class="curtain-root" :class="[{ 'is-opening': introOpening }, introExitClass]" @click="openInvitation" >
@@ -33,10 +33,10 @@
< CanvasEffects :petal-enabled = "data.petalEnabled" :bubble-enabled = "data.bubbleEnabled" / >
<!-- 顶部滚动进度条 -- >
< div class = "absolute top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" : style = "{ width: progress * 100 + '%' }" > < / div >
< div class = "fixed top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" : style = "{ width: progress * 100 + '%' }" > < / div >
<!-- 右上角控制台 -- >
< div class = "absolute top-6 right-5 z-50 flex flex-col items-end gap-4" >
< div class = "fixed top-6 right-5 z-50 flex flex-col items-end gap-4" >
<!-- 音乐控制 -- >
< div class = "music-wrap" >
< div class = "flex flex-col gap-3 items-end" >
@@ -70,9 +70,8 @@
<!-- 主滚动区域 ( 注意 : 不要加 scroll - smooth , 它会劫持 JS 滚动赋值导致卡顿 ) -- >
< div ref = "scrollContainerRef"
class = "w-full h-full overflow-y-auto no-scrollbar relative"
: class = "data.scrollMode === 'snap' ? 'snap-y snap-mandatory' : ''"
@scroll ="handleScroll" @touchstart ="handleUserInteraction" @touchmove ="handleUserInteraction"
class = "w-full min-h-[100vh] no-scrollbar relative"
@touchstart ="handleUserInteraction" @touchmove ="handleUserInteraction"
@wheel ="handleUserInteraction" @mousedown ="handleUserInteraction" >
< ! - - 1. 封面页 - - >
@@ -251,8 +250,8 @@
< / div >
<!-- RSVP 抽屉 -- >
< div v-if = "isDrawerOpen" class="absolute inset-0 z-[60] bg-black/40 backdrop-blur-sm transition-opacity duration-500" @click="closeDrawer" > < / div >
< div class = "absolute bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col" : style = "{ transform: isDrawerOpen ? 'translateY(0)' : 'translateY(100%)' }" >
< div v-if = "isDrawerOpen" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm transition-opacity duration-500" @click="closeDrawer" > < / div >
< div class = "fixed bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col" : style = "{ transform: isDrawerOpen ? 'translateY(0)' : 'translateY(100%)' }" >
< div @click ="closeDrawer" class = "absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl" > ✕ < / div >
< span class = "text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2" > 出席回执 < / span >
< span class = "text-center text-[10px] text-[#8A8680] mb-10 tracking-[0.4em]" > 敬 请 回 复 < / span >
@@ -283,7 +282,7 @@
< / div >
< / div >
< div v-if = "showMapOptions" class="absolute inset-0 z-[80] bg-black/35 backdrop-blur-sm flex items-end justify-center px-4 pb-5" @click.self="showMapOptions = false" >
< div v-if = "showMapOptions" class="fixed inset-0 z-[80] bg-black/35 backdrop-blur-sm flex items-end justify-center px-4 pb-5" @click.self="showMapOptions = false" >
< div class = "map-sheet" >
< div class = "map-sheet-title" > 选择导航方式 < / div >
< button type = "button" @click ="openMap('amap')" > < i class = "fa-solid fa-location-arrow" > < / i > 高德地图 < / button >
@@ -297,11 +296,12 @@
< / template >
< script setup >
import { ref , reactive , computed , onMounted , onUnmounted } from 'vue'
import { ref , reactive , computed , onMounted , onUnmounted , nextTick , watch } from 'vue'
import { getConfig , submitRsvp } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
import CanvasEffects from '@/components/CanvasEffects.vue'
import PhotoGallery from '@/components/PhotoGallery.vue'
import AOS from 'aos'
const DEFAULTS = {
scrollMode : 'snap' , petalEnabled : true , bubbleEnabled : true , introEnabled : true ,
@@ -326,12 +326,17 @@ const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label:
let rafId = null , snapTimer = null , pauseTimer = null , introTimer = null , freeTimer = null , animId = null , scrollStartTimer = null , bottomReturnTimer = null
let freeScrollLastTs = 0
const BOTTOM _RETURN _DELAY = 30000
const FREE _SCROLL _BASE _STEP = 1.2
// 后台可配置的滚动参数(带默认值兜底,防止脏数据)
const freeInterval = computed ( ( ) => {
const v = Number ( data . value . freeScrollInterval )
return v >= 10 && v <= 1000 ? v : 50
} )
const freeScrollSignature = computed ( ( ) => ` ${ data . value . scrollMode } : ${ freeInterval . value } ` )
const restartAutoScroll = ( ) => {
if ( snapTimer || rafId ) startAutoScroll ( )
}
const switchDuration = computed ( ( ) => {
const v = Number ( data . value . pageSwitchDuration )
return v >= 0 && v <= 5000 ? v : 800
@@ -389,12 +394,12 @@ const formalPageHeightStyle = computed(() => sectionHeightStyle(data.value.forma
const pageHeightStyle = ( page ) => sectionHeightStyle ( page . height )
const closeDrawer = ( ) => { isDrawerOpen . value = false }
const pageScrollTop = ( ) => window . scrollY || document . documentElement . scrollTop || document . body . scrollTop || 0
const pageScrollMax = ( ) => Math . max ( 0 , document . documentElement . scrollHeight - window . innerHeight )
const handleScroll = ( ) => {
const el = scrollContainerRef . value
if ( ! el ) return
const max = el . scrollHeight - el . clientHeight
progress . value = max > 0 ? el . scrollTop / max : 0
if ( isAtBottom ( el ) ) scheduleBottomReturn ( )
const max = pageScrollMax ( )
progress . value = max > 0 ? pageScrollTop ( ) / max : 0
if ( isAtBottom ( ) ) scheduleBottomReturn ( )
else clearBottomReturn ( )
}
@@ -403,13 +408,13 @@ const handleUserInteraction = () => {
// 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动
cancelAnimationFrame ( animId )
clearTimeout ( pauseTimer )
if ( isAtBottom ( scrollContainerRef . value ) ) scheduleBottomReturn ( true )
if ( isAtBottom ( ) ) scheduleBottomReturn ( true )
pauseTimer = setTimeout ( ( ) => ( isInteracting . value = false ) , 3000 )
}
const sectionEls = ( ) => Array . from ( scrollContainerRef . value ? . querySelectorAll ( '.page-section' ) || [ ] )
const isAtBottom = ( el ) => ! ! el && el . scrollHeight - el . clientHeight - el . s crollTop <= 8
const isAtBottom = ( ) => pageScrollMax ( ) - pageS crollTop( ) <= 8
const clearBottomReturn = ( ) => {
clearTimeout ( bottomReturnTimer )
@@ -417,45 +422,41 @@ const clearBottomReturn = () => {
}
const scheduleBottomReturn = ( reset = false ) => {
const el = scrollContainerRef . value
if ( ! el || ! isAtBottom ( el ) || ! isAutoScroll . value ) return
if ( ! isAtBottom ( ) || ! isAutoScroll . value ) return
if ( bottomReturnTimer && ! reset ) return
clearBottomReturn ( )
bottomReturnTimer = setTimeout ( ( ) => {
bottomReturnTimer = null
const current = scrollContainerRef . value
if ( ! current || ! isAtBottom ( current ) || ! isAutoScroll . value || isDrawerOpen . value ) return
if ( ! isAtBottom ( ) || ! isAutoScroll . value || isDrawerOpen . value ) return
if ( isInteracting . value ) {
scheduleBottomReturn ( true )
return
}
smoothScrollTo ( current , 0 , switchDuration . value )
smoothScrollTo ( 0 , switchDuration . value )
} , BOTTOM _RETURN _DELAY )
}
// 带时长的平滑滚动( easeInOutQuad) , duration 由后台 pageSwitchDuration 控制
const smoothScrollTo = ( el , target, duration ) => {
const smoothScrollTo = ( target , duration ) => {
cancelAnimationFrame ( animId )
const start = el . s crollTop, dist = target - start
const start = pageS crollTop( ) , dist = target - start
if ( ! dist ) return
if ( duration <= 0 ) { el . scrollTop = target ; return }
if ( duration <= 0 ) { window . scrollTo( 0 , target ) ; return }
const t0 = performance . now ( )
const tick = ( now ) => {
const p = Math . min ( 1 , ( now - t0 ) / duration )
const e = p < 0.5 ? 2 * p * p : 1 - Math . pow ( - 2 * p + 2 , 2 ) / 2
el . scrollTop = start + dist * e
window . scrollTo( 0 , start + dist * e )
if ( p < 1 ) animId = requestAnimationFrame ( tick )
}
animId = requestAnimationFrame ( tick )
}
const goNextSection = ( ) => {
const el = scrollContainerRef . value
if ( ! el ) return
const top = el . scrollTop
const top = pageScrollTop ( )
const next = sectionEls ( ) . find ( ( e ) => e . offsetTop > top + 12 )
if ( next ) {
smoothScrollTo ( el , next. offsetTop , switchDuration . value )
smoothScrollTo ( next . offsetTop , switchDuration . value )
return
}
scheduleBottomReturn ( )
@@ -466,24 +467,23 @@ const startAutoScroll = () => {
clearInterval ( snapTimer )
clearInterval ( freeTimer )
cancelAnimationFrame ( rafId )
rafId = null
if ( data . value . scrollMode === 'snap' ) {
if ( isAutoScroll . value && ! isInteracting . value && ! isDrawerOpen . value ) goNextSection ( )
snapTimer = setInterval ( ( ) => {
if ( isAutoScroll . value && ! isInteracting . value && ! isDrawerOpen . value ) goNextSection ( )
} , 4500 )
} else {
// 自由滚动:按浏览器帧率匀速推进,速度仍由后台间隔控制(间隔越小越快)
freeScrollLastTs = 0
const tickFreeScroll = ( now ) => {
const dt = freeScrollLastTs ? Math . min ( 34 , now - freeScrollLastTs ) : 16.7
freeScrollLastTs = now
const el = scrollContainerRef . value
if ( el && isAutoScroll . value && ! isInteracting . value && ! isDrawerOpen . value ) {
if ( isAtBottom ( el ) ) {
if ( isAutoScroll . value && ! isInteracting . value && ! isDrawerOpen . value ) {
if ( isAtBottom ( ) ) {
scheduleBottomReturn ( )
} else {
el . scrollTop += dt * ( 1.2 / freeInterval . value )
if ( isAtBottom ( el ) ) scheduleBottomReturn ( )
window . scrollTo( 0 , pageScrollTop ( ) + FREE _SCROLL _BASE _STEP * ( dt / freeInterval . value ) )
if ( isAtBottom ( ) ) scheduleBottomReturn ( )
}
}
rafId = requestAnimationFrame ( tickFreeScroll )
@@ -492,6 +492,17 @@ const startAutoScroll = () => {
}
}
const syncPageScrollMode = ( ) => {
document . documentElement . classList . toggle ( 'page-scroll-snap' , data . value . scrollMode === 'snap' )
}
const refreshAos = ( ) => {
requestAnimationFrame ( ( ) => {
AOS . refreshHard ( )
setTimeout ( ( ) => AOS . refresh ( ) , 250 )
} )
}
const scheduleAutoScrollStart = ( ) => {
clearTimeout ( scrollStartTimer )
scrollStartTimer = setTimeout ( ( ) => startAutoScroll ( ) , firstScreenDuration . value )
@@ -506,8 +517,7 @@ const completeIntro = () => {
const toggleAutoScroll = ( ) => {
isAutoScroll . value = ! isAutoScroll . value
const el = scrollContainerRef . value
if ( isAutoScroll . value && isAtBottom ( el ) ) scheduleBottomReturn ( true )
if ( isAutoScroll . value && isAtBottom ( ) ) scheduleBottomReturn ( true )
if ( ! isAutoScroll . value ) clearBottomReturn ( )
}
@@ -572,6 +582,9 @@ const handleSubmitRsvp = async () => {
}
onMounted ( async ( ) => {
if ( 'scrollRestoration' in history ) history . scrollRestoration = 'manual'
window . scrollTo ( 0 , 0 )
try {
const res = await getConfig ( )
if ( res . data && res . data !== '{}' ) {
@@ -592,6 +605,8 @@ onMounted(async () => {
}
} catch ( e ) { /* 使用默认配置 */ }
syncPageScrollMode ( )
if ( data . value . introEnabled ) showIntro . value = true
audio . setTracks ( data . value . musicList , data . value . activeMusicUrl )
@@ -606,10 +621,25 @@ onMounted(async () => {
scheduleAutoScrollStart ( )
}
await nextTick ( )
AOS . init ( {
offset : 40 ,
duration : 500 ,
easing : 'ease-out-cubic' ,
once : true ,
mirror : false ,
throttleDelay : 120 ,
debounceDelay : 80 ,
disableMutationObserver : true ,
} )
refreshAos ( )
window . addEventListener ( 'scroll' , handleScroll , { passive : true } )
handleScroll ( )
} )
onUnmounted ( ( ) => {
document . documentElement . classList . remove ( 'page-scroll-snap' )
window . removeEventListener ( 'scroll' , handleScroll )
cancelAnimationFrame ( rafId )
cancelAnimationFrame ( animId )
clearInterval ( snapTimer )
@@ -619,6 +649,9 @@ onUnmounted(() => {
clearTimeout ( scrollStartTimer )
clearBottomReturn ( )
} )
watch ( ( ) => data . value . scrollMode , syncPageScrollMode )
watch ( freeScrollSignature , restartAutoScroll )
< / script >
< style scoped >
@@ -627,6 +660,12 @@ onUnmounted(() => {
font - variant - numeric : lining - nums ;
text - rendering : geometricPrecision ;
}
: global ( html . page - scroll - snap ) {
scroll - snap - type : y mandatory ;
}
: global ( html . page - scroll - snap body ) {
scroll - snap - type : y mandatory ;
}
. wrapper . font - serif { font - family : var ( -- font - serif ) ; }
. wrapper . font - sans { font - family : var ( -- font - sans ) ; }
. control - btn {