更新页面效果,但是会卡顿
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
60
vite-tailwindcss/src/composables/useImageWarmup.js
Normal file
60
vite-tailwindcss/src/composables/useImageWarmup.js
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
// 带时长的平滑滚动(easeInOutQuad),duration 由后台 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 决定,无需额外形状样式 */
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user