更新页面效果,但是会卡顿

This commit is contained in:
李琦
2026-08-01 13:57:14 +08:00
parent bf8d6f5b0c
commit a6dec89d09
7 changed files with 340 additions and 74 deletions

View File

@@ -111,7 +111,8 @@ func requireAdmin(c *gin.Context) bool {
}
func initDB() {
dsn := "root:mysql_PKC65h@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
dsn := "root:root@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
//dsn := "root:mysql_PKC65h@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {

View File

@@ -1,5 +1,5 @@
<template>
<div class="gallery">
<div ref="galleryRef" 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,18 @@
<!-- 单图默认居中卡幅 / 可配置宽度铺满 -->
<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" @click.stop="previewImage(page.img1)" @load="notifyLayoutChange" /></div>
<div :class="singleFrameClass"><img :src="page.img1" class="preview-img" decoding="async" @click.stop="previewImage(page.img1)" @load="notifyLayoutChange" /></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" @click.stop="previewImage(page.img1)" @load="notifyLayoutChange" />
<img :src="page.img1" class="preview-img" decoding="async" @click.stop="previewImage(page.img1)" @load="notifyLayoutChange" />
</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" @click.stop="previewImage(page.img2)" @load="notifyLayoutChange" />
<img :src="page.img2" class="preview-img" decoding="async" @click.stop="previewImage(page.img2)" @load="notifyLayoutChange" />
</div>
</div>
@@ -29,20 +29,19 @@
<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" @click.stop="previewImage(page.images[0])" @load="notifyLayoutChange" />
<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" />
</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" @click.stop="previewImage(page.images[i])" @load="notifyLayoutChange" />
<img :src="page.images[i]" class="preview-img" decoding="async" @click.stop="previewImage(page.images[i])" @load="notifyLayoutChange" />
</div>
</div>
<!-- 九宫格 -->
<!-- <div v-else-if="page.type === 'nine-grid'" v-reveal="{ variant: 'up', delay: 80 }" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">-->
<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" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
<img :src="img" class="nine-grid-img preview-img" decoding="async" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
</div>
</div>
@@ -50,7 +49,7 @@
<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" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
</div>
</div>
@@ -58,7 +57,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" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
</div>
</div>
<button class="g-arrow g-prev" @click="goTo(index - 1)" aria-label="上一张"></button>
@@ -73,7 +72,7 @@
<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" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" @load="notifyLayoutChange" />
</div>
</div>
@@ -85,11 +84,17 @@
<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'])
const emit = defineEmits(['preview-image', 'layout-change', 'warmup-change'])
const galleryRef = ref(null)
useImageWarmup(galleryRef, {
onBusyChange: (busy) => emit('warmup-change', busy),
})
// 边框样式:前台按 page.borderStyle 套用全局 gf-* 类
const frameClass = computed(() => borderClass(props.page.borderStyle))
@@ -113,6 +118,13 @@ 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)
@@ -136,6 +148,7 @@ function previewImage(src) {
}
function notifyLayoutChange() {
if (!needsLayoutNotify.value) return
emit('layout-change')
}
@@ -148,15 +161,14 @@ function polaroidTransform(i) {
return `rotate(${a}deg) translateY(${s}px)`
}
function getRevealConfig(imgIdx) {
const row = Math.floor(imgIdx / 3)
const col = imgIdx % 3
// 正中心
// 正中心:用 scale 代替 blur避免大图 filter 动画与解码叠峰
if (imgIdx === 4) {
return {
variant: 'blur',
variant: 'scale',
delay: 200,
}
}
@@ -172,7 +184,7 @@ function getRevealConfig(imgIdx) {
// 左右列
return {
variant: col === 0 ? 'left' : 'right',
delay: 80 + ((imgIdx +80) * 20) ,
delay: 80 + ((imgIdx + 80) * 20),
}
}
</script>
@@ -184,7 +196,6 @@ function getRevealConfig(imgIdx) {
font-family: var(--font-calligraphy);
font-weight: 700;
}
/*.gallery-subtitle { font-family: var(--font-display); }*/
.gallery-desc { font-family: var(--font-kai); }
.nine-grid-img {

View File

@@ -0,0 +1,60 @@
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,6 +17,9 @@ 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 }
@@ -27,11 +30,14 @@ 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') && parent.scrollHeight > parent.clientHeight) {
if (overflowY === 'auto' || overflowY === 'scroll') {
return parent
}
parent = parent.parentElement
@@ -39,6 +45,20 @@ function findScrollRoot(el) {
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)
@@ -46,15 +66,37 @@ 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: findScrollRoot(el), rootMargin: '0px 0px -8% 0px' }
{ threshold: 0.12, root: getScrollRoot(el), rootMargin: '0px 0px -8% 0px' }
)
observer.observe(el)
el.__revealObserver = observer
@@ -64,6 +106,7 @@ const reveal = {
el.__revealObserver.disconnect()
delete el.__revealObserver
}
clearWillChange(el)
},
}

View File

@@ -10,7 +10,6 @@
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); }
@@ -21,6 +20,15 @@
.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; }
}
@@ -77,7 +85,8 @@
/* ---------- 背景柔光层(淡金/淡粉光斑,缓慢漂移,不喧宾夺主) ---------- */
.bg-glow { position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.bg-glow i { position: absolute; border-radius: 50%; filter: blur(46px); will-change: transform; }
/* 柔光靠 radial-gradient 自身衰减,不再叠 CSS filter:blur滚动时绘制成本很高 */
.bg-glow i { position: absolute; border-radius: 50%; }
.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

@@ -92,7 +92,7 @@
<div v-reveal="{ variant: 'scale', delay: 120 }" class="w-full max-w-[260px] aspect-[3/4] relative z-10">
<div class="absolute inset-0 border border-[#a88c6b] translate-x-3 translate-y-3 opacity-30"></div>
<div class="relative w-full h-full z-10 bg-[#F7F6F2] shadow-md overflow-hidden">
<img :src="data.heroImg" class="w-full h-full object-cover" />
<img :src="data.heroImg" class="w-full h-full object-cover" fetchpriority="high" decoding="async" alt="封面" />
</div>
</div>
@@ -128,16 +128,16 @@
<!-- 3-N. 画廊循环 -->
<div v-for="(page, pIdx) in data.photoPages" :key="pIdx" class="page-section p-6 relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''" :style="pageHeightStyle(page)">
<div class="absolute inset-0 bg-white/40 backdrop-blur-[2px] -z-10"></div>
<div class="absolute inset-0 bg-white/40 -z-10"></div>
<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="scheduleScrollMetricsUpdate" />
<PhotoGallery :page="page" @preview-image="openImagePreview" @layout-change="scheduleScrollMetricsUpdateDebounced" @warmup-change="onGalleryWarmupChange" />
</div>
</div>
<!-- 婚礼日历 -->
<div class="page-section wedding-calendar-page px-6 py-10 relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div ref="calendarPageRef" 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 +160,12 @@
<span v-if="day === weddingDay" class="heart-ring" aria-hidden="true">
<svg viewBox="0 0 120 96" role="img">
<defs>
<filter id="calendar-heart-rough" x="-18%" y="-18%" width="136%" height="136%">
<filter v-if="calendarRoughActive" 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="url(#calendar-heart-rough)">
<g :filter="calendarRoughActive ? 'url(#calendar-heart-rough)' : undefined">
<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,8 +226,8 @@
<span>With Love</span>
<i></i>
</div>
<div v-reveal="{ variant: 'scale', delay: 100 }" class="ending-photo">
<img :src="endImage" class="w-full h-full bg-[#F7F6F2] object-cover" alt="婚礼尾页照片" />
<div ref="endingPhotoRef" 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">
<span class="ending-title calligraphy">期待您的光临</span>
@@ -313,6 +313,7 @@
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'
@@ -336,14 +337,24 @@ const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(fal
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 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(() => {
@@ -352,7 +363,7 @@ const freeInterval = computed(() => {
})
const freeScrollSignature = computed(() => `${data.value.scrollMode}:${freeInterval.value}`)
const restartAutoScroll = () => {
if (snapTimer || rafId) startAutoScroll()
if (isAutoScroll.value) startAutoScroll()
}
const switchDuration = computed(() => {
const v = Number(data.value.pageSwitchDuration)
@@ -448,11 +459,14 @@ 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(() => ({
@@ -476,13 +490,23 @@ const openImagePreview = async (src) => {
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)
const effectsPerformanceMode = computed(() =>
(data.value.scrollMode === 'free' && isAutoScroll.value) || galleryWarmupCount.value > 0
)
const onGalleryWarmupChange = (busy) => {
galleryWarmupCount.value = Math.max(0, galleryWarmupCount.value + (busy ? 1 : -1))
}
useImageWarmup(endingPhotoRef, { onBusyChange: onGalleryWarmupChange })
const updateScrollMetrics = () => {
const el = scrollContainerRef.value
@@ -492,33 +516,87 @@ const updateScrollMetrics = () => {
return max
}
const scheduleScrollMetricsUpdate = () => {
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)
if (isAtBottom()) scheduleBottomReturn()
else clearBottomReturn()
}
const flushScrollMetrics = () => {
cancelAnimationFrame(metricsRaf)
metricsRaf = requestAnimationFrame(() => {
metricsRaf = null
updateScrollMetrics()
handleScroll()
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
const max = maxScrollTop.value || updateScrollMetrics()
progress.value = max > 0 ? Math.min(1, el.scrollTop / max) : 0
if (progressBarRef.value) progressBarRef.value.style.transform = `scaleX(${progress.value})`
if (isAtBottom()) scheduleBottomReturn()
else clearBottomReturn()
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), 3000)
pauseTimer = setTimeout(() => {
isInteracting.value = false
maybeResumeFreeScroll()
}, 3000)
}
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
@@ -526,7 +604,8 @@ const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('
const isAtBottom = () => {
const el = scrollContainerRef.value
if (!el) return false
const max = maxScrollTop.value || updateScrollMetrics()
let max = maxScrollTop.value
if (max <= 0) max = updateScrollMetrics()
return max - el.scrollTop <= 8
}
@@ -540,6 +619,7 @@ const scheduleBottomReturn = (reset = false) => {
if (!el || !isAtBottom() || !isAutoScroll.value || isAutoScrollBlocked()) return
if (bottomReturnTimer && !reset) return
clearBottomReturn()
stopFreeScrollLoop()
bottomReturnTimer = setTimeout(() => {
bottomReturnTimer = null
if (!isAtBottom() || !isAutoScroll.value || isAutoScrollBlocked()) return
@@ -551,20 +631,37 @@ 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) return
if (duration <= 0) { el.scrollTop = target; return }
if (!dist) {
onProgrammaticScrollDone()
return
}
if (duration <= 0) {
setProgrammaticScrollTop(el, target)
onProgrammaticScrollDone()
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
setProgrammaticScrollTop(el, start + dist * e)
if (p < 1) animId = requestAnimationFrame(tick)
else {
animId = null
onProgrammaticScrollDone()
}
}
animId = requestAnimationFrame(tick)
}
@@ -585,40 +682,52 @@ const startAutoScroll = () => {
clearTimeout(scrollStartTimer)
clearInterval(snapTimer)
clearInterval(freeTimer)
cancelAnimationFrame(rafId)
rafId = null
stopFreeScrollLoop()
if (data.value.scrollMode === 'snap') {
if (isAutoScroll.value && !isAutoScrollBlocked()) goNextSection()
snapTimer = setInterval(() => {
if (isAutoScroll.value && !isAutoScrollBlocked()) goNextSection()
}, 4500)
} else {
freeScrollLastTs = 0
freeScrollCarry = 0
const tickFreeScroll = (now) => {
const el = scrollContainerRef.value
const dt = freeScrollLastTs ? Math.min(24, now - freeScrollLastTs) : 16.7
freeScrollLastTs = now
if (el && isAutoScroll.value && !isAutoScrollBlocked()) {
const max = maxScrollTop.value || updateScrollMetrics()
if (max - el.scrollTop <= 8) {
scheduleBottomReturn()
} else {
freeScrollCarry += FREE_SCROLL_BASE_STEP * (dt / freeInterval.value)
if (freeScrollCarry >= 0.35) {
const currentTop = el.scrollTop
const nextTop = Math.min(max, currentTop + freeScrollCarry)
el.scrollTop = nextTop
freeScrollCarry = Math.max(0, freeScrollCarry - (nextTop - currentTop))
if (freeScrollCarry > 4) freeScrollCarry = 0
if (max - nextTop <= 8) scheduleBottomReturn()
}
}
}
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
}
}
rafId = requestAnimationFrame(tickFreeScroll)
}
rafId = requestAnimationFrame(tickFreeScroll)
}
const scheduleAutoScrollStart = () => {
@@ -635,8 +744,15 @@ const completeIntro = () => {
const toggleAutoScroll = () => {
isAutoScroll.value = !isAutoScroll.value
if (isAutoScroll.value && isAtBottom()) scheduleBottomReturn(true)
if (!isAutoScroll.value) clearBottomReturn()
if (!isAutoScroll.value) {
clearBottomReturn()
stopFreeScrollLoop()
cancelAnimationFrame(animId)
animId = null
return
}
if (isAtBottom()) scheduleBottomReturn(true)
else startAutoScroll()
}
// 入场揭幕:开门动画
@@ -726,6 +842,14 @@ onMounted(async () => {
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
audio.setTracks(data.value.musicList, data.value.activeMusicUrl)
@@ -744,19 +868,29 @@ onMounted(async () => {
})
onUnmounted(() => {
cancelAnimationFrame(rafId)
stopFreeScrollLoop()
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(freeScrollSignature, restartAutoScroll)
watch(
() => [isDrawerOpen.value, previewVisible.value, showMapOptions.value],
() => {
if (isAutoScrollBlocked()) stopFreeScrollLoop()
else maybeResumeFreeScroll()
}
)
</script>
<style scoped>
@@ -808,7 +942,15 @@ watch(freeScrollSignature, restartAutoScroll)
line-height: 1.8;
font-weight: 700;
}
.page-section { min-height: 100vh; width: 100%; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; }
.page-section {
min-height: 100vh;
width: 100%;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
/* 花瓣形状由 SVG 决定,无需额外形状样式 */

View File

@@ -19,7 +19,7 @@ export default defineConfig({
proxy: {
// 婚礼纪 Go 后端运行在 :8080接口统一以 /api 开头,无需重写
'/api': {
target: 'http://127.0.0.1:8080',
target: 'http://127.0.0.1:15201',
changeOrigin: true
}
}