恢复滚动方案

This commit is contained in:
李琦
2026-08-01 14:55:21 +08:00
parent a6dec89d09
commit f65000336a
97 changed files with 98 additions and 3197 deletions

View File

@@ -8,8 +8,8 @@ import { onMounted, onUnmounted, watch, ref } from 'vue'
const props = defineProps({
petalEnabled: { type: Boolean, default: true },
bubbleEnabled: { type: Boolean, default: true },
/** 预览/抽屉等遮罩时关闭粒子绘制 */
active: { type: Boolean, default: true },
performanceMode: { type: Boolean, default: false },
})
const canvasRef = ref(null)
@@ -24,7 +24,6 @@ let width = 0
let height = 0
let dpr = 1
let lastTime = 0
let lastPaint = 0
let resizeObserver = null
let reduceMotion = false
@@ -55,16 +54,14 @@ function resetHeart(h, initial = false) {
}
function seedParticles() {
const petalCount = props.performanceMode ? 8 : 18
const heartCount = props.performanceMode ? 5 : 12
petals.length = 0
hearts.length = 0
for (let i = 0; i < petalCount; i += 1) {
for (let i = 0; i < 18; i += 1) {
const p = {}
resetPetal(p, true)
petals.push(p)
}
for (let i = 0; i < heartCount; i += 1) {
for (let i = 0; i < 12; i += 1) {
const h = {}
resetHeart(h, true)
hearts.push(h)
@@ -77,7 +74,7 @@ function resize() {
const rect = canvas.getBoundingClientRect()
width = Math.max(1, rect.width)
height = Math.max(1, rect.height)
dpr = props.performanceMode ? 1 : Math.min(window.devicePixelRatio || 1, 1.5)
dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
ctx = canvas.getContext('2d')
@@ -130,15 +127,8 @@ function tick(now) {
return
}
const frameInterval = props.performanceMode ? 50 : 16.7
if (lastPaint && now - lastPaint < frameInterval) {
rafId = requestAnimationFrame(tick)
return
}
const delta = Math.min(48, now - (lastTime || now)) / 1000
lastTime = now
lastPaint = now
ctx.clearRect(0, 0, width, height)
if (props.petalEnabled) {
@@ -164,7 +154,6 @@ function tick(now) {
function start() {
cancelAnimationFrame(rafId)
lastTime = 0
lastPaint = 0
if (!props.active || reduceMotion || (!props.petalEnabled && !props.bubbleEnabled)) {
ctx?.clearRect(0, 0, width, height)
rafId = 0
@@ -181,13 +170,7 @@ onMounted(() => {
start()
})
watch(
() => [props.petalEnabled, props.bubbleEnabled, props.active, props.performanceMode],
() => {
resize()
start()
}
)
watch(() => [props.petalEnabled, props.bubbleEnabled, props.active], start)
onUnmounted(() => {
cancelAnimationFrame(rafId)
@@ -203,7 +186,5 @@ onUnmounted(() => {
width: 100%;
height: 100vh;
pointer-events: none;
contain: strict;
transform: translateZ(0);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="galleryRef" class="gallery">
<div class="gallery">
<!-- 标题区 -->
<div v-reveal="'up'" class="text-center mb-5 px-4">
<span class="gallery-title calligraphy text-xl text-[#a88c6b] tracking-[0.3em] font-bold block mb-2">{{ page.title }}</span>
@@ -10,18 +10,20 @@
<!-- 单图默认居中卡幅 / 可配置宽度铺满 -->
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
<div :class="singleFrameClass"><img :src="page.img1" class="preview-img" decoding="async" @click.stop="previewImage(page.img1)" @load="notifyLayoutChange" /></div>
<div :class="singleFrameClass">
<img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" />
</div>
</div>
<!-- 错落双图交错拼贴左上大图 + 右下压角叠放微旋转clamp 封顶不溢出 -->
<!-- 错落双图 -->
<div v-else-if="page.type === 'overlap'" class="overlap-wrap relative w-full mt-2">
<div v-reveal="{ variant: 'left', delay: 100 }"
class="absolute left-0 top-0 w-[72%] h-[78%] z-10 -rotate-2" :class="frameClass">
<img :src="page.img1" class="preview-img" decoding="async" @click.stop="previewImage(page.img1)" @load="notifyLayoutChange" />
<img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" />
</div>
<div v-reveal="{ variant: 'right', delay: 300 }"
class="absolute right-0 bottom-0 w-[56%] h-[62%] z-20 rotate-3" :class="frameClass">
<img :src="page.img2" class="preview-img" decoding="async" @click.stop="previewImage(page.img2)" @load="notifyLayoutChange" />
<img :src="page.img2" class="preview-img" @click.stop="previewImage(page.img2)" />
</div>
</div>
@@ -29,27 +31,27 @@
<div v-else-if="page.type === 'one-large-five-small'"
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20">
<div v-reveal="{ variant: 'scale', delay: 100 }" class="col-span-2 row-span-2 relative" :class="frameClass">
<img :src="page.images[0]" class="preview-img hover:scale-105 transition-transform duration-1000" decoding="async" @click.stop="previewImage(page.images[0])" @load="notifyLayoutChange" />
<img :src="page.images[0]" class="preview-img hover:scale-105 transition-transform duration-1000" @click.stop="previewImage(page.images[0])" />
</div>
<div v-for="i in [1,2,3,4,5]" :key="i" v-reveal="{ variant: 'up', delay: i * 80 }" class="relative" :class="frameClass">
<img :src="page.images[i]" class="preview-img" decoding="async" @click.stop="previewImage(page.images[i])" @load="notifyLayoutChange" />
<img :src="page.images[i]" class="preview-img" @click.stop="previewImage(page.images[i])" />
</div>
</div>
<!-- 九宫格 -->
<!-- 九宫格保留方向性 v-reveal 新动画 -->
<div v-else-if="page.type === 'nine-grid'" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx"
v-reveal="getRevealConfig(imgIdx)"
class="aspect-square relative" :class="frameClass">
<img :src="img" class="nine-grid-img preview-img" decoding="async" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
<img :src="img" class="nine-grid-img preview-img" @click.stop="previewImage(img)" />
</div>
</div>
<!-- 瀑布流错落多列 -->
<!-- 瀑布流 -->
<div v-else-if="page.type === 'masonry'" class="g-masonry mt-6 px-1">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'up', delay: imgIdx * 50 }"
class="g-masonry-item" :class="frameClass">
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
</div>
</div>
@@ -57,7 +59,7 @@
<div v-else-if="page.type === 'carousel'" class="relative mt-6">
<div ref="trackRef" class="g-carousel-track no-scrollbar" @scroll="onScroll">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
</div>
</div>
<button class="g-arrow g-prev" @click="goTo(index - 1)" aria-label="上一张"></button>
@@ -68,15 +70,14 @@
</div>
</div>
<!-- 拍立得拼贴自带边框不受 borderStyle 影响 -->
<!-- 拍立得拼贴 -->
<div v-else-if="page.type === 'polaroid'" class="g-polaroid mt-6">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 70 }"
class="g-polaroid-item" :style="{ transform: polaroidTransform(imgIdx) }">
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
</div>
</div>
<!-- 兜底 -->
<div v-else class="text-center text-[#8A8680] text-[12px] mt-6">未识别的版式{{ page.type }}</div>
</div>
</template>
@@ -84,48 +85,28 @@
<script setup>
import { ref, computed } from 'vue'
import { borderClass } from '@/composables/borderStyles'
import { useImageWarmup } from '@/composables/useImageWarmup'
const props = defineProps({
page: { type: Object, required: true },
})
const emit = defineEmits(['preview-image', 'layout-change', 'warmup-change'])
const emit = defineEmits(['preview-image'])
const galleryRef = ref(null)
useImageWarmup(galleryRef, {
onBusyChange: (busy) => emit('warmup-change', busy),
})
// 边框样式:前台按 page.borderStyle 套用全局 gf-* 类
const frameClass = computed(() => borderClass(props.page.borderStyle))
/* ---------- 单图宽度模式 ---------- */
// singleWidth: 'default'(居中卡幅) | 'full'(宽度铺满)singleKeepRatio: 铺满时是否保持原始宽高比
const singleWrapClass = computed(() =>
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
)
const singleWrapStyle = computed(() => {
if (props.page.singleWidth === 'full') {
// 沾满宽度:负边距抵消 page-section 的 p-624px×2图片贴到屏幕两缘
const bleed = { width: 'calc(100% + 48px)', marginLeft: '-24px', marginRight: '-24px' }
// 保持原始比例 → 高度由图片自然撑开;否则固定 4:5 裁切
return props.page.singleKeepRatio ? bleed : { ...bleed, aspectRatio: '4 / 5' }
}
return { aspectRatio: '4 / 5' }
})
// 保持原始比例时 img 需要自然高度gf-natural 覆盖 gf-frame 的 height:100%
const singleFrameClass = computed(() =>
frameClass.value + (props.page.singleWidth === 'full' && props.page.singleKeepRatio ? ' gf-natural' : '')
)
/** 仅高度可能随图片加载变化的版式才通知父级重算 scroll metrics */
const needsLayoutNotify = computed(() => {
if (props.page.height === 'auto') return true
if (props.page.type === 'single' && props.page.singleWidth === 'full' && props.page.singleKeepRatio) return true
return false
})
/* ---------- 轮播状态 ---------- */
const index = ref(0)
const trackRef = ref(null)
@@ -147,12 +128,6 @@ function previewImage(src) {
if (src) emit('preview-image', src)
}
function notifyLayoutChange() {
if (!needsLayoutNotify.value) return
emit('layout-change')
}
/* ---------- 拍立得旋转/位移 ---------- */
const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5]
const polaroidShift = [0, 10, -8, 6, -10, 8, 4, -6]
function polaroidTransform(i) {
@@ -165,23 +140,15 @@ function getRevealConfig(imgIdx) {
const row = Math.floor(imgIdx / 3)
const col = imgIdx % 3
// 正中心:用 scale 代替 blur避免大图 filter 动画与解码叠峰
if (imgIdx === 4) {
return {
variant: 'scale',
delay: 200,
}
return { variant: 'scale', delay: 200 }
}
// 中间列:上 → down下 → up
if (col === 1) {
return {
variant: row === 0 ? 'down' : 'up',
delay: 80 + imgIdx * 50,
}
}
// 左右列
return {
variant: col === 0 ? 'left' : 'right',
delay: 80 + ((imgIdx + 80) * 20),
@@ -208,14 +175,11 @@ function getRevealConfig(imgIdx) {
}
.nine-grid-img:hover { transform: translateZ(0) scale(1.03); }
/* 错落双图:高度随视口缩放并封顶,保证标题+图片总高始终落在页高预算内 */
.overlap-wrap { height: clamp(320px, 54vh, 460px); }
/* 瀑布流 */
.g-masonry { column-count: 2; column-gap: 10px; }
.g-masonry-item { break-inside: avoid; margin-bottom: 10px; border-radius: 6px; }
/* 轮播 */
.g-carousel-track {
display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
border-radius: 10px; scrollbar-width: none;
@@ -235,7 +199,6 @@ function getRevealConfig(imgIdx) {
.g-dot { width: 7px; height: 7px; border-radius: 9999px; border: none; background: rgba(168,140,107,0.3); padding: 0; cursor: pointer; transition: all 0.3s; }
.g-dot.active { background: #a88c6b; width: 18px; border-radius: 9999px; }
/* 拍立得 */
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
.g-polaroid-item {
width: 42%; background: #fff; padding: 8px 8px 24px; border-radius: 2px;

View File

@@ -1,60 +0,0 @@
import { onMounted, onUnmounted } from 'vue'
/**
* 提前约 1.5 屏预热区块内图片解码,避免滚入视口时与 free-scroll rAF 撞车。
* onBusyChange(true/false) 供父级短暂降低 canvas 粒子负载。
*/
export function useImageWarmup(rootRef, { onBusyChange } = {}) {
let observer = null
let busy = false
let warmToken = 0
const setBusy = (next) => {
if (busy === next) return
busy = next
onBusyChange?.(next)
}
const warmImages = async (el) => {
const token = ++warmToken
setBusy(true)
const imgs = el.querySelectorAll('img[src]')
await Promise.allSettled(
[...imgs].map((img) => {
if (typeof img.decode === 'function') {
return img.decode().catch(() => {})
}
if (img.complete) return Promise.resolve()
return new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true })
img.addEventListener('error', resolve, { once: true })
})
})
)
if (token === warmToken) setBusy(false)
}
onMounted(() => {
const el = rootRef.value
if (!el || typeof IntersectionObserver === 'undefined') return
const scrollRoot = el.closest('.scrollable-area-body')
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) warmImages(el)
else {
warmToken += 1
setBusy(false)
}
},
{ root: scrollRoot, rootMargin: '150% 0px', threshold: 0 }
)
observer.observe(el)
})
onUnmounted(() => {
warmToken += 1
observer?.disconnect()
observer = null
setBusy(false)
})
}

View File

@@ -17,9 +17,6 @@ const VARIANTS = {
blur: 'reveal-blur',
}
/** 同一滚动根只解析一次,避免每个 v-reveal 挂载都 getComputedStyle 爬树 */
let cachedScrollRoot = undefined
function parseBinding(value) {
if (!value) return { variant: 'up', delay: 0 }
if (typeof value === 'string') return { variant: value, delay: 0 }
@@ -29,36 +26,6 @@ function parseBinding(value) {
}
}
function findScrollRoot(el) {
// 请柬主滚动容器(避免被轮播 overflow-x 等内层滚动误匹配)
const named = el.closest?.('.scrollable-area-body')
if (named) return named
let parent = el.parentElement
while (parent && parent !== document.body) {
const style = window.getComputedStyle(parent)
const overflowY = style.overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
return parent
}
parent = parent.parentElement
}
return null
}
function getScrollRoot(el) {
if (cachedScrollRoot !== undefined) return cachedScrollRoot
cachedScrollRoot = findScrollRoot(el)
return cachedScrollRoot
}
function clearWillChange(el) {
el.style.willChange = 'auto'
}
function hasImageContent(el) {
return el.tagName === 'IMG' || !!el.querySelector?.('img')
}
const reveal = {
mounted(el, binding) {
const { variant, delay } = parseBinding(binding.value)
@@ -66,37 +33,15 @@ const reveal = {
el.classList.add(VARIANTS[variant] || 'reveal-up')
if (delay) el.style.transitionDelay = `${delay}ms`
// 含图片的节点不做 filter 过渡(大图 + blur 极贵),保留 opacity/transform
const imageHost = hasImageContent(el)
if (imageHost) {
el.classList.add('reveal--no-filter')
if (variant === 'blur') {
el.classList.remove('reveal-blur')
el.classList.add('reveal-scale')
}
}
// 已处于视口(如首屏)时立即显示,避免永远不可见
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.style.willChange = imageHost ? 'opacity, transform' : 'opacity, transform, filter'
el.classList.add('reveal--in')
observer.unobserve(el)
const onEnd = (e) => {
if (e.target !== el) return
clearWillChange(el)
el.removeEventListener('transitionend', onEnd)
}
el.addEventListener('transitionend', onEnd)
// 兜底:无 transition 或已被 reduce-motion 关掉时仍清 will-change
setTimeout(() => {
clearWillChange(el)
el.removeEventListener('transitionend', onEnd)
}, 1400 + (delay || 0))
}
},
{ threshold: 0.12, root: getScrollRoot(el), rootMargin: '0px 0px -8% 0px' }
{ threshold: 0.12, rootMargin: '0px 0px -8% 0px' }
)
observer.observe(el)
el.__revealObserver = observer
@@ -106,7 +51,6 @@ const reveal = {
el.__revealObserver.disconnect()
delete el.__revealObserver
}
clearWillChange(el)
},
}

View File

@@ -10,6 +10,7 @@
opacity 1.1s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 1.1s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 1.1s ease;
will-change: opacity, transform, filter;
}
.reveal-up { transform: translateY(48px); }
.reveal-down { transform: translateY(-48px); }
@@ -20,15 +21,6 @@
.reveal--in { opacity: 1; transform: none; filter: none; }
/* 图片宿主:不做 filter 过渡,避免大图 decode + blur 叠峰 */
.reveal--no-filter {
transition:
opacity 1.1s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 1.1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
filter: none !important;
}
.reveal--no-filter.reveal--in { filter: none !important; }
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; }
}
@@ -85,8 +77,7 @@
/* ---------- 背景柔光层(淡金/淡粉光斑,缓慢漂移,不喧宾夺主) ---------- */
.bg-glow { position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
/* 柔光靠 radial-gradient 自身衰减,不再叠 CSS filter:blur滚动时绘制成本很高 */
.bg-glow i { position: absolute; border-radius: 50%; }
.bg-glow i { position: absolute; border-radius: 50%; filter: blur(46px); will-change: transform; }
.bg-glow .g1 {
width: 300px; height: 300px; top: -90px; right: -70px;
background: radial-gradient(circle, rgba(233, 206, 168, 0.5), transparent 68%);

View File

@@ -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">
@@ -34,14 +34,13 @@
:petal-enabled="data.petalEnabled"
:bubble-enabled="data.bubbleEnabled"
:active="effectsActive"
:performance-mode="effectsPerformanceMode"
/>
<!-- 顶部滚动进度条 -->
<div ref="progressBarRef" class="absolute top-0 left-0 w-full h-[2px] bg-[#a88c6b] z-[55] origin-left transform-gpu scale-x-0"></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">
@@ -75,9 +74,7 @@
<!-- 主滚动区域注意不要加 scroll-smooth它会劫持 JS 滚动赋值导致卡顿 -->
<div ref="scrollContainerRef"
class="w-full h-full overflow-y-auto no-scrollbar relative scrollable-area-body"
:class="data.scrollMode === 'snap' ? 'snap-y snap-mandatory' : ''"
@scroll="handleScroll"
class="w-full min-h-[100vh] no-scrollbar relative"
@touchstart="handleUserInteraction" @touchmove="handleUserInteraction"
@wheel="handleUserInteraction" @mousedown="handleUserInteraction">
@@ -132,12 +129,12 @@
<div class="absolute top-0 left-0 w-24 h-24 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
<div class="absolute bottom-0 right-0 w-24 h-24 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
<div class="w-full h-full flex flex-col justify-center py-6 relative z-10">
<PhotoGallery :page="page" @preview-image="openImagePreview" @layout-change="scheduleScrollMetricsUpdateDebounced" @warmup-change="onGalleryWarmupChange" />
<PhotoGallery :page="page" @preview-image="openImagePreview" />
</div>
</div>
<!-- 婚礼日历 -->
<div ref="calendarPageRef" class="page-section wedding-calendar-page px-6 py-10 relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="page-section wedding-calendar-page px-6 py-10 relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div v-reveal="'up'" class="calendar-card">
<div class="calendar-kicker">Save The Date</div>
<div class="calendar-title">
@@ -160,12 +157,12 @@
<span v-if="day === weddingDay" class="heart-ring" aria-hidden="true">
<svg viewBox="0 0 120 96" role="img">
<defs>
<filter v-if="calendarRoughActive" id="calendar-heart-rough" x="-18%" y="-18%" width="136%" height="136%">
<filter id="calendar-heart-rough" x="-18%" y="-18%" width="136%" height="136%">
<feTurbulence type="fractalNoise" baseFrequency="0.045" numOctaves="3" seed="11" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.8" />
</filter>
</defs>
<g :filter="calendarRoughActive ? 'url(#calendar-heart-rough)' : undefined">
<g filter="url(#calendar-heart-rough)">
<path class="heart-brush heart-brush-shadow" d="M54 82 C31 70 12 50 13 29 C14 10 35 7 49 27 C56 37 58 49 59 56 C64 37 80 10 99 20 C113 28 105 51 90 66 C80 76 70 81 64 83" />
<path class="heart-brush heart-brush-main" d="M54 82 C31 70 12 50 13 29 C14 10 35 7 49 27 C56 37 58 49 59 56 C64 37 80 10 99 20 C113 28 105 51 90 66 C80 76 70 81 64 83" />
<path class="heart-brush heart-brush-tail" d="M94 61 C87 62 82 58 78 52" />
@@ -226,7 +223,7 @@
<span>With Love</span>
<i></i>
</div>
<div ref="endingPhotoRef" v-reveal="{ variant: 'scale', delay: 100 }" class="ending-photo">
<div v-reveal="{ variant: 'scale', delay: 100 }" class="ending-photo">
<img :src="endImage" class="w-full h-full bg-[#F7F6F2] object-cover" loading="lazy" decoding="async" alt="婚礼尾页照片" />
</div>
<div v-reveal="{ variant: 'up', delay: 180 }" class="ending-copy">
@@ -313,7 +310,6 @@
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { getAlbumImages, getConfig, submitRsvp } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
import { useImageWarmup } from '@/composables/useImageWarmup'
import CanvasEffects from '@/components/CanvasEffects.vue'
import PhotoGallery from '@/components/PhotoGallery.vue'
@@ -332,38 +328,25 @@ const data = ref({"date": "2026年9月4日 星期五", "bride": "李逸烁", "gr
const audio = useAudio()
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSubmitted = ref(false)
const isInteracting = ref(false), scrollContainerRef = ref(null), progressBarRef = ref(null), progress = ref(0)
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0)
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMapOptions = ref(false)
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
const albumPreviewImages = ref([])
const maxScrollTop = ref(0)
const calendarPageRef = ref(null)
const calendarRoughActive = ref(false)
const endingPhotoRef = ref(null)
const galleryWarmupCount = ref(0)
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null, metricsRaf = null
let metricsDebounceTimer = null
let calendarRoughObserver = null
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
let freeScrollLastTs = 0
let freeScrollCarry = 0
let programmaticScroll = false
let progressPaintTs = 0
const BOTTOM_RETURN_DELAY = 30000
const FREE_SCROLL_BASE_STEP = 1.2
const PROGRESS_PAINT_INTERVAL = 1000 / 12
const METRICS_DEBOUNCE_MS = 120
// 后台可配置的滚动参数(带默认值兜底,防止脏数据)
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 (isAutoScroll.value) startAutoScroll()
if (snapTimer || rafId) startAutoScroll()
}
const switchDuration = computed(() => {
const v = Number(data.value.pageSwitchDuration)
@@ -413,7 +396,6 @@ const calendarDays = computed(() => {
return [...cells, ...Array.from({ length: trailing }, () => null)]
})
// 页面高度:支持 100vh / 80vh / 60vh / auto / 自定义 vh
const sectionHeightStyle = (height) => {
const h = height || '100vh'
return h === 'auto' ? { minHeight: 'auto' } : { minHeight: h }
@@ -459,14 +441,9 @@ const handlePreviewVisibleChange = (visible) => {
if (visible) {
clearBottomReturn()
cancelAnimationFrame(animId)
animId = null
stopFreeScrollLoop()
return
}
freeScrollLastTs = 0
freeScrollCarry = 0
if (isAutoScroll.value && isAtBottom()) scheduleBottomReturn(true)
else maybeResumeFreeScroll()
}
const albumPreviewOptions = computed(() => ({
@@ -487,127 +464,34 @@ const openImagePreview = async (src) => {
}
previewIndex.value = index
previewVisible.value = true
freeScrollCarry = 0
clearBottomReturn()
cancelAnimationFrame(animId)
animId = null
stopFreeScrollLoop()
}
const isAutoScrollBlocked = () =>
isInteracting.value || isDrawerOpen.value || previewVisible.value || showMapOptions.value || showIntro.value
const effectsActive = computed(() => !isDrawerOpen.value && !previewVisible.value && !showMapOptions.value && !showIntro.value)
const effectsPerformanceMode = computed(() =>
(data.value.scrollMode === 'free' && isAutoScroll.value) || galleryWarmupCount.value > 0
const effectsActive = computed(() =>
!isDrawerOpen.value && !previewVisible.value && !showMapOptions.value && !showIntro.value
)
const onGalleryWarmupChange = (busy) => {
galleryWarmupCount.value = Math.max(0, galleryWarmupCount.value + (busy ? 1 : -1))
}
useImageWarmup(endingPhotoRef, { onBusyChange: onGalleryWarmupChange })
const updateScrollMetrics = () => {
const el = scrollContainerRef.value
if (!el) return 0
const max = Math.max(0, el.scrollHeight - el.clientHeight)
maxScrollTop.value = max
return max
}
const paintProgressBar = (scrollTop, force = false) => {
const now = performance.now()
if (!force && now - progressPaintTs < PROGRESS_PAINT_INTERVAL) return
progressPaintTs = now
const max = maxScrollTop.value
const p = max > 0 ? Math.min(1, scrollTop / max) : 0
progress.value = p
if (progressBarRef.value) progressBarRef.value.style.transform = `scaleX(${p})`
}
/** 程序化改 scrollTop避免同步 @scroll 再走布局/响应式热路径 */
const setProgrammaticScrollTop = (el, top) => {
programmaticScroll = true
el.scrollTop = top
programmaticScroll = false
paintProgressBar(top)
}
const applyScrollSideEffects = () => {
const el = scrollContainerRef.value
if (!el) return
paintProgressBar(el.scrollTop, true)
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 max = pageScrollMax()
progress.value = max > 0 ? pageScrollTop() / max : 0
if (isAtBottom()) scheduleBottomReturn()
else clearBottomReturn()
}
const flushScrollMetrics = () => {
cancelAnimationFrame(metricsRaf)
metricsRaf = requestAnimationFrame(() => {
metricsRaf = null
updateScrollMetrics()
applyScrollSideEffects()
})
}
const scheduleScrollMetricsUpdate = () => {
clearTimeout(metricsDebounceTimer)
metricsDebounceTimer = null
flushScrollMetrics()
}
const scheduleScrollMetricsUpdateDebounced = () => {
clearTimeout(metricsDebounceTimer)
metricsDebounceTimer = setTimeout(() => {
metricsDebounceTimer = null
flushScrollMetrics()
}, METRICS_DEBOUNCE_MS)
}
const closeDrawer = () => { isDrawerOpen.value = false }
const handleScroll = () => {
if (programmaticScroll) return
const el = scrollContainerRef.value
if (!el) return
if (maxScrollTop.value <= 0) updateScrollMetrics()
applyScrollSideEffects()
}
const stopFreeScrollLoop = () => {
cancelAnimationFrame(rafId)
rafId = null
}
const maybeResumeFreeScroll = () => {
if (data.value.scrollMode !== 'free') return
if (!isAutoScroll.value || isAutoScrollBlocked() || rafId || bottomReturnTimer) return
startAutoScroll()
}
const handleUserInteraction = () => {
isInteracting.value = true
// 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动
cancelAnimationFrame(animId)
animId = null
stopFreeScrollLoop()
clearTimeout(pauseTimer)
if (isAtBottom()) scheduleBottomReturn(true)
pauseTimer = setTimeout(() => {
isInteracting.value = false
maybeResumeFreeScroll()
}, 3000)
pauseTimer = setTimeout(() => (isInteracting.value = false), 3000)
}
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
const isAtBottom = () => {
const el = scrollContainerRef.value
if (!el) return false
let max = maxScrollTop.value
if (max <= 0) max = updateScrollMetrics()
return max - el.scrollTop <= 8
}
const isAtBottom = () => pageScrollMax() - pageScrollTop() <= 8
const clearBottomReturn = () => {
clearTimeout(bottomReturnTimer)
@@ -615,14 +499,12 @@ const clearBottomReturn = () => {
}
const scheduleBottomReturn = (reset = false) => {
const el = scrollContainerRef.value
if (!el || !isAtBottom() || !isAutoScroll.value || isAutoScrollBlocked()) return
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value) return
if (bottomReturnTimer && !reset) return
clearBottomReturn()
stopFreeScrollLoop()
bottomReturnTimer = setTimeout(() => {
bottomReturnTimer = null
if (!isAtBottom() || !isAutoScroll.value || isAutoScrollBlocked()) return
if (!isAtBottom() || !isAutoScroll.value || isDrawerOpen.value || previewVisible.value) return
if (isInteracting.value) {
scheduleBottomReturn(true)
return
@@ -631,45 +513,23 @@ const scheduleBottomReturn = (reset = false) => {
}, BOTTOM_RETURN_DELAY)
}
const onProgrammaticScrollDone = () => {
applyScrollSideEffects()
maybeResumeFreeScroll()
}
// 带时长的平滑滚动easeInOutQuadduration 由后台 pageSwitchDuration 控制
const smoothScrollTo = (target, duration) => {
const el = scrollContainerRef.value
if (!el) return
cancelAnimationFrame(animId)
animId = null
const start = el.scrollTop, dist = target - start
if (!dist) {
onProgrammaticScrollDone()
return
}
if (duration <= 0) {
setProgrammaticScrollTop(el, target)
onProgrammaticScrollDone()
return
}
const start = pageScrollTop(), dist = target - start
if (!dist) 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
setProgrammaticScrollTop(el, start + dist * e)
window.scrollTo(0, start + dist * e)
if (p < 1) animId = requestAnimationFrame(tick)
else {
animId = null
onProgrammaticScrollDone()
}
}
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(next.offsetTop, switchDuration.value)
@@ -682,52 +542,34 @@ const startAutoScroll = () => {
clearTimeout(scrollStartTimer)
clearInterval(snapTimer)
clearInterval(freeTimer)
stopFreeScrollLoop()
cancelAnimationFrame(rafId)
rafId = null
if (data.value.scrollMode === 'snap') {
if (isAutoScroll.value && !isAutoScrollBlocked()) goNextSection()
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value && !previewVisible.value) goNextSection()
snapTimer = setInterval(() => {
if (isAutoScroll.value && !isAutoScrollBlocked()) goNextSection()
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value && !previewVisible.value) goNextSection()
}, 4500)
return
}
if (!isAutoScroll.value || isAutoScrollBlocked() || bottomReturnTimer) return
if (maxScrollTop.value <= 0) updateScrollMetrics()
freeScrollLastTs = 0
freeScrollCarry = 0
const tickFreeScroll = (now) => {
const el = scrollContainerRef.value
if (!el || !isAutoScroll.value || isAutoScrollBlocked()) {
rafId = null
return
}
const dt = freeScrollLastTs ? Math.min(24, now - freeScrollLastTs) : 16.7
freeScrollLastTs = now
const max = maxScrollTop.value
if (max <= 0) {
rafId = requestAnimationFrame(tickFreeScroll)
return
}
if (max - el.scrollTop <= 8) {
scheduleBottomReturn()
rafId = null
return
}
freeScrollCarry += FREE_SCROLL_BASE_STEP * (dt / freeInterval.value)
if (freeScrollCarry >= 0.35) {
const currentTop = el.scrollTop
const nextTop = Math.min(max, currentTop + freeScrollCarry)
setProgrammaticScrollTop(el, nextTop)
freeScrollCarry = Math.max(0, freeScrollCarry - (nextTop - currentTop))
if (freeScrollCarry > 4) freeScrollCarry = 0
if (max - nextTop <= 8) {
scheduleBottomReturn()
rafId = null
return
} else {
freeScrollLastTs = 0
const tickFreeScroll = (now) => {
const dt = freeScrollLastTs ? Math.min(34, now - freeScrollLastTs) : 16.7
freeScrollLastTs = now
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value && !previewVisible.value && !showMapOptions.value) {
if (isAtBottom()) {
scheduleBottomReturn()
} else {
window.scrollTo(0, pageScrollTop() + FREE_SCROLL_BASE_STEP * (dt / freeInterval.value))
if (isAtBottom()) scheduleBottomReturn()
}
}
rafId = requestAnimationFrame(tickFreeScroll)
}
rafId = requestAnimationFrame(tickFreeScroll)
}
rafId = requestAnimationFrame(tickFreeScroll)
}
const syncPageScrollMode = () => {
document.documentElement.classList.toggle('page-scroll-snap', data.value.scrollMode === 'snap')
}
const scheduleAutoScrollStart = () => {
@@ -744,30 +586,20 @@ const completeIntro = () => {
const toggleAutoScroll = () => {
isAutoScroll.value = !isAutoScroll.value
if (!isAutoScroll.value) {
clearBottomReturn()
stopFreeScrollLoop()
cancelAnimationFrame(animId)
animId = null
return
}
if (isAtBottom()) scheduleBottomReturn(true)
else startAutoScroll()
if (isAutoScroll.value && isAtBottom()) scheduleBottomReturn(true)
if (!isAutoScroll.value) clearBottomReturn()
}
// 入场揭幕:开门动画
const openInvitation = () => {
if (introOpening.value) return
clearTimeout(introTimer)
introOpening.value = true
introTimer = setTimeout(() => completeIntro(), introExitDuration.value)
// 点击揭幕是明确的用户手势:直接尝试播放背景音乐(最可靠的自动播放路径)
if (data.value.musicList.length) audio.play()
}
const selectTrack = (url) => { audio.setActive(url); showTrackList.value = false }
const mapKeyword = computed(() => `${data.value.hotel || ''} ${data.value.address || ''}`.replace(/\s+/g, ' ').trim())
const isMobileDevice = () => /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
const isIOSDevice = () => /iPhone|iPad|iPod/i.test(navigator.userAgent)
@@ -816,11 +648,13 @@ 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 !== '{}') {
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
// 兼容旧版单链接 musicUrl
if (loaded.musicUrl && !(loaded.musicList && loaded.musicList.length)) {
loaded.musicList = [{ url: loaded.musicUrl, name: '背景音乐' }]
loaded.activeMusicUrl = loaded.musicUrl
@@ -836,19 +670,9 @@ onMounted(async () => {
}
} catch (e) { /* 使用默认配置 */ }
syncPageScrollMode()
albumLoaded.value = false
albumPreviewImages.value = localAlbumImages()
await nextTick()
scheduleScrollMetricsUpdate()
window.addEventListener('resize', scheduleScrollMetricsUpdate, { passive: true })
if (calendarPageRef.value) {
calendarRoughObserver = new IntersectionObserver(
([entry]) => { calendarRoughActive.value = !!entry?.isIntersecting },
{ root: scrollContainerRef.value, rootMargin: '20% 0px', threshold: 0.01 }
)
calendarRoughObserver.observe(calendarPageRef.value)
}
if (data.value.introEnabled) showIntro.value = true
@@ -864,33 +688,36 @@ onMounted(async () => {
scheduleAutoScrollStart()
}
scheduleScrollMetricsUpdate()
await nextTick()
window.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
})
onUnmounted(() => {
stopFreeScrollLoop()
document.documentElement.classList.remove('page-scroll-snap')
window.removeEventListener('scroll', handleScroll)
cancelAnimationFrame(rafId)
cancelAnimationFrame(animId)
cancelAnimationFrame(metricsRaf)
clearTimeout(metricsDebounceTimer)
clearInterval(snapTimer)
clearInterval(freeTimer)
clearTimeout(pauseTimer)
clearTimeout(introTimer)
clearTimeout(scrollStartTimer)
clearBottomReturn()
calendarRoughObserver?.disconnect()
calendarRoughObserver = null
window.removeEventListener('resize', scheduleScrollMetricsUpdate)
})
watch(() => data.value.scrollMode, syncPageScrollMode)
watch(freeScrollSignature, restartAutoScroll)
watch(
() => [isDrawerOpen.value, previewVisible.value, showMapOptions.value],
() => {
if (isAutoScrollBlocked()) stopFreeScrollLoop()
else maybeResumeFreeScroll()
if (isDrawerOpen.value || previewVisible.value || showMapOptions.value) {
cancelAnimationFrame(animId)
clearBottomReturn()
}
}
)
</script>
<style scoped>
@@ -902,16 +729,11 @@ watch(
.album-preview-registry {
display: none;
}
.wrapper .scrollable-area-body {
width: 100%;
box-sizing: border-box;
:global(html.page-scroll-snap) {
scroll-snap-type: y mandatory;
}
@media (min-width: 1024px) {
.wrapper .scrollable-area-body {
max-width: 1200px;
margin: 0 auto;
}
: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); }