634 lines
35 KiB
Vue
634 lines
35 KiB
Vue
<template>
|
||
<div class="wrapper relative w-full h-[100vh] bg-[#Fdfbf7] overflow-hidden text-[#2c2c2c]">
|
||
|
||
<!-- 入场揭幕帘幕(雅致双开门 + 徽标) -->
|
||
<div v-show="showIntro" class="curtain-root" :class="{ 'is-opening': introOpening }" @click="openInvitation">
|
||
<div class="curtain-bg" :style="curtainBg"></div>
|
||
<div class="curtain-veil"></div>
|
||
<div class="curtain-door curtain-door-left"><span class="door-sheen"></span></div>
|
||
<div class="curtain-door curtain-door-right"><span class="door-sheen"></span></div>
|
||
<div class="curtain-emblem">
|
||
<div class="emblem-inner">
|
||
<span class="emblem-floral">❀</span>
|
||
<span class="emblem-kicker">WEDDING INVITATION</span>
|
||
<div class="emblem-names">{{ data.groom }}<span class="amp">&</span>{{ data.bride }}</div>
|
||
<div class="emblem-rule"></div>
|
||
<div class="emblem-date">{{ data.date }}</div>
|
||
<div class="emblem-hint"><span class="dot"></span>轻触开启请柬</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 背景柔光层(淡金/淡粉光斑,缓慢漂移) -->
|
||
<div class="bg-glow" aria-hidden="true"><i class="g1"></i><i class="g2"></i><i class="g3"></i></div>
|
||
|
||
<!-- 飘落花瓣层(外层位移 + 内层摇曳,全 transform 不触发布局) -->
|
||
<div v-if="data.petalEnabled" class="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
|
||
<div v-for="p in petals" :key="'p'+p.id" class="petal"
|
||
:style="{ left: p.left, width: p.size+'px', height: p.size+'px', opacity: p.opacity, animationDelay: p.delay, '--p-dur': p.duration }">
|
||
<svg viewBox="0 0 100 100" class="petal-inner" :style="{ '--p-sway': p.sway, filter: p.filter }">
|
||
<path d="M50 3 C28 26 20 62 50 97 C80 62 72 26 50 3 Z" :fill="p.color" opacity="0.9" />
|
||
<path d="M50 9 C50 36 50 70 50 91" stroke="rgba(255,255,255,0.55)" stroke-width="1.4" fill="none" opacity="0.5" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 漂浮爱心层(三层景深:远小淡模糊 / 近大清晰) -->
|
||
<div v-if="data.bubbleEnabled" class="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
|
||
<div v-for="b in bubbles" :key="'b'+b.id"
|
||
class="heart-bubble flex items-center justify-center"
|
||
:style="{ left: b.left, top: b.top, color: b.color, fontSize: b.size+'px', animationDelay: b.delay, '--float-duration': b.floatDuration, '--h-opacity': b.opacity }">
|
||
<span class="heart-inner" :style="{ '--sway-duration': b.swayDuration, filter: b.filter }">♥</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 顶部滚动进度条 -->
|
||
<div class="absolute 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="music-wrap">
|
||
<div class="flex flex-col gap-3 items-end">
|
||
<div @click="audio.toggle()" class="control-btn cursor-pointer" :class="{ 'rotate-spin': audio.isPlaying.value }">
|
||
<img v-if="audio.isPlaying.value" src="https://api.iconify.design/lucide:music.svg?color=%23a88c6b" class="w-4 h-4" />
|
||
<img v-else src="https://api.iconify.design/lucide:volume-x.svg?color=%23a88c6b" class="w-4 h-4" />
|
||
</div>
|
||
<div v-if="audio.tracks.value.length > 1" @click="showTrackList = !showTrackList" class="control-btn cursor-pointer">
|
||
<img src="https://api.iconify.design/lucide:list-music.svg?color=%23a88c6b" class="w-4 h-4" />
|
||
</div>
|
||
</div>
|
||
<!-- 曲目切换弹层 -->
|
||
<div v-if="showTrackList && audio.tracks.value.length" class="track-pop">
|
||
<div class="track-pop-title">选择曲目</div>
|
||
<div v-for="t in audio.tracks.value" :key="t.url"
|
||
class="track-item" :class="{ active: t.url === audio.activeUrl.value }"
|
||
@click="selectTrack(t.url)">
|
||
<span class="eq" v-if="t.url === audio.activeUrl.value && audio.isPlaying.value"><i></i><i></i><i></i></span>
|
||
<span class="tname">{{ t.name }}</span>
|
||
<span class="tcheck" v-if="t.url === audio.activeUrl.value">✓</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div @click="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300"
|
||
:class="isAutoScroll ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30'">
|
||
<img v-if="isAutoScroll" src="https://api.iconify.design/lucide:pause.svg?color=%23a88c6b" class="w-4 h-4" />
|
||
<img v-else src="https://api.iconify.design/lucide:play.svg?color=%23a88c6b" class="w-4 h-4 ml-0.5" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主滚动区域(注意:不要加 scroll-smooth,它会劫持 JS 滚动赋值导致卡顿) -->
|
||
<div ref="scrollContainerRef"
|
||
class="w-full h-full overflow-y-auto no-scrollbar relative"
|
||
:class="data.scrollMode === 'snap' ? 'snap-y snap-mandatory' : ''"
|
||
@scroll="handleScroll" @touchstart="handleUserInteraction" @touchmove="handleUserInteraction"
|
||
@wheel="handleUserInteraction" @mousedown="handleUserInteraction">
|
||
|
||
<!-- 1. 封面页 -->
|
||
<div class="page-section" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
|
||
<div class="flex flex-col items-center justify-between py-12 h-full w-full relative border-[0.5px] border-[#a88c6b]/20 bg-white/40">
|
||
<div v-reveal="'down'" class="text-center mt-6 z-10">
|
||
<span class="text-[10px] tracking-[0.5em] uppercase text-[#a88c6b] font-light block">Wedding Invitation</span>
|
||
<div class="w-6 h-[0.5px] bg-[#a88c6b] mx-auto opacity-50 mt-4"></div>
|
||
</div>
|
||
|
||
<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 will-change-transform" :style="{ transform: `translateY(${parallax}px) scale(1.12)` }" />
|
||
</div>
|
||
</div>
|
||
|
||
<div v-reveal="{ variant: 'up', delay: 200 }" class="text-center z-10 mb-10 space-y-4 px-4">
|
||
<div class="flex items-end justify-center gap-6 text-3xl font-light tracking-[0.2em]">
|
||
<span>{{ data.groom }}</span>
|
||
<span class="text-[#a88c6b] text-xl pb-1 italic font-serif">&</span>
|
||
<span>{{ data.bride }}</span>
|
||
</div>
|
||
<div class="flex flex-col items-center text-[11px] text-[#8A8680] tracking-[0.2em] mt-4 uppercase">
|
||
<span>{{ data.date }}</span>
|
||
<div class="w-4 h-[0.5px] bg-[#a88c6b]/40 my-2"></div>
|
||
<span>{{ data.lunar }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. 竖排文案页 -->
|
||
<div class="page-section bg-[#F7F6F2] items-center px-8 relative justify-center" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
|
||
<div class="absolute top-0 left-0 w-32 h-32 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6"></div>
|
||
<div class="absolute bottom-0 right-0 w-32 h-32 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6"></div>
|
||
<div v-reveal="'blur'" class="flex flex-col items-center w-full">
|
||
<div class="text-3xl opacity-60 mb-14 text-[#a88c6b] font-serif float-soft">❀</div>
|
||
<div class="flex justify-center gap-10 h-72 text-[#3A3A3A] text-lg leading-loose font-light">
|
||
<span class="vertical-text">{{ data.formalText1 }}</span>
|
||
<span class="vertical-text" style="padding-top: 3rem;">{{ data.formalText2 }}</span>
|
||
<span class="vertical-text text-[#8A8680] text-[12px] ml-4 border-l-[0.5px] border-[#a88c6b]/30 pl-6 h-56 mt-8 tracking-[0.3em]">诚挚邀请您见证我们的幸福</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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 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" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 婚礼流程 -->
|
||
<div class="page-section bg-transparent px-6 z-10 justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
|
||
<div class="absolute inset-0 bg-[#Fdfbf7]/60 backdrop-blur-[2px] -z-10"></div>
|
||
<div v-reveal="'up'" class="text-center mb-14">
|
||
<span class="text-2xl text-[#a88c6b] tracking-[0.3em] font-light block">婚礼流程</span>
|
||
<span class="text-[10px] text-[#8A8680] font-sans uppercase tracking-[0.4em] mt-3 block">Wedding Schedule</span>
|
||
</div>
|
||
<div class="relative w-full px-2 max-w-[340px] mx-auto">
|
||
<div class="absolute left-1/2 top-2 bottom-4 w-[0.5px] bg-[#a88c6b]/40 -translate-x-1/2"></div>
|
||
<div v-for="(item, index) in data.schedule" :key="index" class="flex w-full mb-10 relative z-10" :class="index % 2 === 0 ? 'justify-start' : 'justify-end'">
|
||
<div v-reveal="{ variant: index % 2 === 0 ? 'left' : 'right', delay: index * 120 }"
|
||
class="w-[45%] flex flex-col relative bg-white/60 p-4 rounded-xl shadow-sm border-[0.5px] border-[#a88c6b]/10"
|
||
:class="index % 2 === 0 ? 'text-right items-end' : 'text-left items-start'">
|
||
<div class="absolute top-[22px] w-2 h-2 rounded-full bg-[#a88c6b] shadow-[0_0_0_4px_rgba(168,140,107,0.15)]" :class="index % 2 === 0 ? '-right-[28px]' : '-left-[28px]'"></div>
|
||
<span class="text-xl font-serif text-[#a88c6b] mb-1 font-light tracking-wider">{{ item.time }}</span>
|
||
<span class="text-[13px] tracking-[0.2em] text-[#3a3a3a] font-medium mb-1">{{ item.event }}</span>
|
||
<span class="text-[9px] tracking-[0.1em] text-[#8A8680] font-sans uppercase">{{ item.desc }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 尾页 -->
|
||
<div class="page-section ending-page px-6 py-8 flex flex-col items-center justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
|
||
<div class="ending-frame" aria-hidden="true"></div>
|
||
<div class="ending-corners" aria-hidden="true"><i></i><i></i></div>
|
||
<div class="w-full max-w-[340px] flex flex-col items-center z-10 relative">
|
||
<div v-reveal="'down'" class="ending-kicker">
|
||
<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>
|
||
<div v-reveal="{ variant: 'up', delay: 180 }" class="ending-copy">
|
||
<span class="ending-title">期待您的光临</span>
|
||
<span class="ending-names">{{ data.groom }} & {{ data.bride }}</span>
|
||
<span class="ending-date">{{ data.date }}</span>
|
||
</div>
|
||
<div v-reveal="{ variant: 'up', delay: 240 }" class="ending-venue">
|
||
<span class="ending-hotel">{{ data.hotel }}</span>
|
||
<span class="ending-address">{{ data.address }}</span>
|
||
</div>
|
||
<div v-reveal="{ variant: 'up', delay: 300 }" class="w-full max-w-[280px]">
|
||
<button class="rsvp-cta" @click="isDrawerOpen = true">
|
||
<span class="rsvp-cta-deco">❀</span>
|
||
<span class="rsvp-cta-text">填写出席回执</span>
|
||
<span class="rsvp-cta-line"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RSVP 抽屉 -->
|
||
<div v-if="isDrawerOpen" class="absolute inset-0 z-[60] bg-black/40 backdrop-blur-sm transition-opacity duration-500" @click="closeDrawer"></div>
|
||
<div class="absolute bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col" :style="{ transform: isDrawerOpen ? 'translateY(0)' : 'translateY(100%)' }">
|
||
<div @click="closeDrawer" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl">✕</div>
|
||
<span class="text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2">出席回执</span>
|
||
<span class="text-center text-[10px] text-[#8A8680] mb-10 tracking-[0.4em]">敬 请 回 复</span>
|
||
|
||
<div v-if="!isSubmitted" class="space-y-6">
|
||
<div>
|
||
<span class="block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest pl-1">姓名</span>
|
||
<input v-model="rsvpForm.name" placeholder="您的姓名" class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/30 rounded-xl px-5 py-4 text-[13px] focus:outline-none focus:border-[#a88c6b] shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]" />
|
||
</div>
|
||
<div>
|
||
<span class="block text-[10px] text-[#a88c6b] mb-2 tracking-widest pl-1">出席情况</span>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button v-for="opt in rsvpOptions" :key="opt.value" type="button"
|
||
class="rsvp-tag" :class="{ active: rsvpForm.guest_count === opt.value }"
|
||
@click="rsvpForm.guest_count = opt.value">{{ opt.label }}</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest pl-1">祝福语</span>
|
||
<textarea v-model="rsvpForm.wishes" placeholder="写下您的祝福..." class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/30 rounded-xl px-5 py-4 text-[13px] h-24 focus:outline-none resize-none shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]"></textarea>
|
||
</div>
|
||
<div @click="handleSubmitRsvp" class="w-full bg-[#a88c6b] text-white py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[#967b5c] transition-all shadow-md">确认发送</div>
|
||
</div>
|
||
<div v-else class="text-center py-10 flex flex-col items-center animate-fade-up">
|
||
<span class="text-3xl text-[#a88c6b] mb-6 animate-pulse-heart">♥</span>
|
||
<span class="text-lg text-[#2c2c2c] mb-3 tracking-[0.2em] font-light">感恩您的祝福</span>
|
||
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light">回执已妥投,期待与您共享喜悦</span>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||
import { getConfig, submitRsvp } from '@/api/wedding'
|
||
import { useAudio } from '@/composables/useAudio'
|
||
import PhotoGallery from '@/components/PhotoGallery.vue'
|
||
|
||
const DEFAULTS = {
|
||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||
musicList: [], activeMusicUrl: '',
|
||
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
|
||
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
|
||
}
|
||
|
||
const data = ref({
|
||
groom: '李明', bride: '王华', date: '2026-05-20', lunar: '农历四月初四 星期三',
|
||
hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
|
||
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称',
|
||
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
|
||
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
|
||
photoPages: [
|
||
{ type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大,还是遇见了你。\n于千万人之中,没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80', borderStyle: 'mat' },
|
||
{ type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初,岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80', borderStyle: 'mat' },
|
||
{ type: 'one-large-five-small', title: '相恋', subtitle: 'ROMANCE', desc: '星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此,还有无数个明天。', images: Array.from({ length: 6 }, () => 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=600&q=80'), borderStyle: 'mat' },
|
||
{ type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生,风雪是你,平淡是你。', images: Array.from({ length: 9 }, () => 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=400&q=80'), borderStyle: 'mat' },
|
||
],
|
||
schedule: [ { time: '16:00', event: '签到迎宾', desc: 'Welcome' }, { time: '17:08', event: '仪式开始', desc: 'Ceremony' }, { time: '18:00', event: '敬备喜宴', desc: 'Banquet' } ],
|
||
...DEFAULTS,
|
||
})
|
||
|
||
const audio = useAudio()
|
||
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSubmitted = ref(false)
|
||
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0), parallax = ref(0)
|
||
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMusicAuth = ref(false)
|
||
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
|
||
|
||
// 后台可配置的滚动参数(带默认值兜底,防止脏数据)
|
||
const freeInterval = computed(() => {
|
||
const v = Number(data.value.freeScrollInterval)
|
||
return v >= 10 && v <= 1000 ? v : 50
|
||
})
|
||
const switchDuration = computed(() => {
|
||
const v = Number(data.value.pageSwitchDuration)
|
||
return v >= 0 && v <= 5000 ? v : 800
|
||
})
|
||
|
||
// 装饰元素配色(柔化后的低饱和金粉系,与主题协调不抢眼)
|
||
const loveColors = ['#c9b08a', '#e2b3c0', '#d4af37', '#e8c9d2']
|
||
const petalColors = ['#f7dce3', '#fbeee3', '#f3c9d6', '#efdcc8', '#f9e7d6']
|
||
|
||
// 爱心三层景深:远(小/淡/模糊) 中 近(大/清晰),营造纵深而不杂乱
|
||
const heartTiers = [
|
||
{ n: 5, size: [10, 13], opacity: [0.1, 0.16], blur: 1.6, dur: [20, 26] }, // 远景
|
||
{ n: 4, size: [14, 17], opacity: [0.16, 0.24], blur: 0.7, dur: [16, 21] }, // 中景
|
||
{ n: 3, size: [18, 22], opacity: [0.24, 0.32], blur: 0, dur: [13, 17] }, // 近景
|
||
]
|
||
let heartId = 0
|
||
const bubbles = heartTiers.flatMap((t) =>
|
||
Array.from({ length: t.n }).map(() => {
|
||
const id = heartId++
|
||
return {
|
||
id,
|
||
left: Math.random() * 100 + '%', top: Math.random() * 100 + '%',
|
||
color: loveColors[id % loveColors.length],
|
||
size: t.size[0] + Math.random() * (t.size[1] - t.size[0]),
|
||
opacity: +(t.opacity[0] + Math.random() * (t.opacity[1] - t.opacity[0])).toFixed(2),
|
||
filter: t.blur ? `blur(${t.blur}px)` : 'none',
|
||
floatDuration: (t.dur[0] + Math.random() * (t.dur[1] - t.dur[0])).toFixed(1) + 's',
|
||
swayDuration: (4.5 + Math.random() * 2.5).toFixed(1) + 's',
|
||
delay: '-' + (Math.random() * 20).toFixed(1) + 's',
|
||
}
|
||
})
|
||
)
|
||
// 花瓣:两层景深,远处更小更淡带轻微模糊
|
||
const petals = Array.from({ length: 16 }).map((_, i) => {
|
||
const far = i % 3 === 0
|
||
return {
|
||
id: i, left: Math.random() * 100 + '%',
|
||
size: far ? 8 + Math.random() * 7 : 13 + Math.random() * 11,
|
||
opacity: far ? 0.3 + Math.random() * 0.15 : 0.42 + Math.random() * 0.3,
|
||
color: petalColors[i % petalColors.length],
|
||
filter: far ? 'blur(1.2px) drop-shadow(0 2px 3px rgba(150,110,90,0.08))' : 'drop-shadow(0 2px 3px rgba(150,110,90,0.12))',
|
||
duration: (far ? 13 : 9) + Math.random() * 8 + 's',
|
||
delay: (Math.random() * 10) + 's',
|
||
sway: (4 + Math.random() * 4) + 's',
|
||
}
|
||
})
|
||
|
||
const curtainBg = computed(() => ({ backgroundImage: `url(${data.value.heroImg})` }))
|
||
const endImage = computed(() => data.value.endImg || data.value.heroImg)
|
||
|
||
// 相册页页面高度:支持 100vh / 80vh / 60vh / auto / 自定义 vh
|
||
const pageHeightStyle = (page) => {
|
||
const h = page.height || '100vh'
|
||
return h === 'auto' ? { minHeight: 'auto' } : { minHeight: h }
|
||
}
|
||
|
||
const closeDrawer = () => { isDrawerOpen.value = false }
|
||
const authorizeMusic = () => { audio.play(); showMusicAuth.value = false }
|
||
audio.play()
|
||
const handleScroll = () => {
|
||
const el = scrollContainerRef.value
|
||
if (!el) return
|
||
const max = el.scrollHeight - el.clientHeight
|
||
progress.value = max > 0 ? el.scrollTop / max : 0
|
||
parallax.value = el.scrollTop * 0.25
|
||
}
|
||
|
||
const handleUserInteraction = () => {
|
||
isInteracting.value = true
|
||
// 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动
|
||
cancelAnimationFrame(animId)
|
||
clearTimeout(pauseTimer)
|
||
pauseTimer = setTimeout(() => (isInteracting.value = false), 3000)
|
||
}
|
||
|
||
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
|
||
|
||
// 带时长的平滑滚动(easeInOutQuad),duration 由后台 pageSwitchDuration 控制
|
||
const smoothScrollTo = (el, target, duration) => {
|
||
cancelAnimationFrame(animId)
|
||
const start = el.scrollTop, dist = target - start
|
||
if (!dist) return
|
||
if (duration <= 0) { el.scrollTop = target; return }
|
||
const t0 = performance.now()
|
||
const tick = (now) => {
|
||
const p = Math.min(1, (now - t0) / duration)
|
||
const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2
|
||
el.scrollTop = start + dist * e
|
||
if (p < 1) animId = requestAnimationFrame(tick)
|
||
}
|
||
animId = requestAnimationFrame(tick)
|
||
}
|
||
|
||
const goNextSection = () => {
|
||
const el = scrollContainerRef.value
|
||
if (!el) return
|
||
const top = el.scrollTop
|
||
const next = sectionEls().find((e) => e.offsetTop > top + 12)
|
||
// 循环逻辑:滚到最后一屏后回到第一屏继续轮播
|
||
smoothScrollTo(el, next ? next.offsetTop : 0, switchDuration.value)
|
||
}
|
||
|
||
const startAutoScroll = () => {
|
||
if (data.value.scrollMode === 'snap') {
|
||
snapTimer = setInterval(() => {
|
||
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection()
|
||
}, 4500)
|
||
} else {
|
||
// 自由滚动:setInterval 按后台配置的间隔匀速推进(间隔越小越快)
|
||
freeTimer = setInterval(() => {
|
||
const el = scrollContainerRef.value
|
||
if (el && isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) {
|
||
el.scrollTop += 1.2
|
||
if (el.scrollTop >= el.scrollHeight - el.clientHeight - 5) el.scrollTop = 0
|
||
}
|
||
}, freeInterval.value)
|
||
}
|
||
}
|
||
|
||
const toggleAutoScroll = () => {
|
||
isAutoScroll.value = !isAutoScroll.value
|
||
const el = scrollContainerRef.value
|
||
if (isAutoScroll.value && el && el.scrollTop >= el.scrollHeight - el.clientHeight - 10) el.scrollTop = 0
|
||
}
|
||
|
||
// 入场揭幕:开门动画
|
||
const openInvitation = () => {
|
||
if (introOpening.value) return
|
||
introOpening.value = true
|
||
introTimer = setTimeout(() => { showIntro.value = false }, 1000)
|
||
// 点击揭幕是明确的用户手势:直接尝试播放背景音乐(最可靠的自动播放路径)
|
||
if (data.value.musicList.length) audio.play()
|
||
}
|
||
const onMusicAuthSkip = () => {
|
||
showMusicAuth.value = false
|
||
// 用户暂不开启:挂起首次交互解锁,后续任意点击仍可播放
|
||
audio.armAutoplay()
|
||
}
|
||
const selectTrack = (url) => { audio.setActive(url); showTrackList.value = false }
|
||
|
||
const handleSubmitRsvp = async () => {
|
||
if (!rsvpForm.name.trim()) return alert('请填写姓名')
|
||
try {
|
||
const res = await submitRsvp(rsvpForm)
|
||
if (res.code === 200) isSubmitted.value = true
|
||
else alert(res.error || '提交失败')
|
||
} catch (e) { alert('提交失败,请检查后端运行状态') }
|
||
}
|
||
|
||
onMounted(async () => {
|
||
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
|
||
}
|
||
loaded.endImg = loaded.endImg || loaded.heroImg || data.value.endImg
|
||
data.value = { ...data.value, ...DEFAULTS, ...loaded }
|
||
}
|
||
} catch (e) { /* 使用默认配置 */ }
|
||
|
||
audio.setTracks(data.value.musicList, data.value.activeMusicUrl)
|
||
// 先静默尝试自动播放;被浏览器拦截(无用户手势)时,再弹出授权模态框
|
||
if (data.value.musicList.length) {
|
||
const autoOk = await audio.attemptAutoplay()
|
||
if (!autoOk) showMusicAuth.value = true
|
||
}
|
||
if (data.value.introEnabled) {
|
||
showIntro.value = true
|
||
introTimer = setTimeout(() => openInvitation(), 3600)
|
||
}
|
||
|
||
startAutoScroll()
|
||
handleScroll()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
cancelAnimationFrame(rafId)
|
||
cancelAnimationFrame(animId)
|
||
clearInterval(snapTimer)
|
||
clearInterval(freeTimer)
|
||
clearTimeout(pauseTimer)
|
||
clearTimeout(introTimer)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.wrapper { font-family: "PingFang SC", sans-serif; }
|
||
.control-btn {
|
||
width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; border-radius: 9999px;
|
||
backdrop-filter: blur(12px); border: 0.5px solid rgba(168, 140, 107, 0.3); color: #a88c6b; background: rgba(255,255,255,0.6);
|
||
}
|
||
.vertical-text { writing-mode: vertical-rl; letter-spacing: 0.4em; text-orientation: upright; }
|
||
.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 决定,无需额外形状样式 */
|
||
|
||
/* ---------- 入场揭幕(雅致象牙金) ---------- */
|
||
.curtain-root { position: fixed; inset: 0; z-index: 100; overflow: hidden; cursor: pointer; font-family: "PingFang SC", sans-serif; background: #fbf6ef; }
|
||
.curtain-bg {
|
||
position: absolute; inset: -24px; background-size: cover; background-position: center;
|
||
filter: blur(16px) brightness(0.7) saturate(1.05); transform: scale(1.12);
|
||
}
|
||
.curtain-veil { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(251,246,239,0.35), rgba(232,214,194,0.25)); }
|
||
.curtain-door {
|
||
position: absolute; top: 0; bottom: 0; width: 50%; z-index: 2;
|
||
background: linear-gradient(160deg, #fbf6ef 0%, #f5e9dc 45%, #ecd9c4 100%);
|
||
box-shadow: inset 0 0 50px rgba(184,154,120,0.12);
|
||
transition: transform 1.4s cubic-bezier(0.76, 0, 0.24, 1);
|
||
}
|
||
.curtain-door::before { content: ''; position: absolute; top: 0; bottom: 0; width: 1px; background: linear-gradient(180deg, transparent, rgba(184,154,120,0.5), transparent); }
|
||
.curtain-door-left { left: 0; }
|
||
.curtain-door-right { right: 0; }
|
||
.curtain-door-left::before { right: 0; }
|
||
.curtain-door-right::before { left: 0; }
|
||
.door-sheen { position: absolute; inset: 0; background: linear-gradient(115deg, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, rgba(255,255,255,0.25) 100%); }
|
||
.curtain-root.is-opening .curtain-door-left { transform: translateX(-100%); }
|
||
.curtain-root.is-opening .curtain-door-right { transform: translateX(100%); }
|
||
.curtain-emblem {
|
||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 3;
|
||
transition: opacity 0.8s ease, transform 0.9s ease; animation: emblemIn 1.1s ease both;
|
||
}
|
||
.curtain-root.is-opening .curtain-emblem { opacity: 0; transform: scale(1.25); }
|
||
.emblem-inner { text-align: center; color: #5b4a36; padding: 10px; }
|
||
.emblem-floral { display: block; font-size: 22px; color: #b89a78; margin-bottom: 14px; }
|
||
.emblem-kicker { display: block; letter-spacing: 0.6em; font-size: 9px; color: #b89a78; text-transform: uppercase; margin-bottom: 18px; padding-left: 0.6em; }
|
||
.emblem-names { font-family: "Noto Serif SC", serif; font-size: 30px; letter-spacing: 0.18em; font-weight: 300; color: #4a3f30; }
|
||
.emblem-names .amp { font-style: italic; color: #b89a78; margin: 0 8px; }
|
||
.emblem-rule { width: 46px; height: 1px; background: linear-gradient(90deg, transparent, #b89a78, transparent); margin: 18px auto; }
|
||
.emblem-date { font-size: 11px; letter-spacing: 0.3em; color: #8a7458; }
|
||
.emblem-hint { margin-top: 26px; font-size: 11px; letter-spacing: 0.3em; color: #a08a66;
|
||
display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||
.emblem-hint .dot { width: 6px; height: 6px; border-radius: 9999px; background: #b89a78; animation: hintPulse 1.6s infinite; }
|
||
|
||
/* ---------- 尾页 ---------- */
|
||
.ending-page {
|
||
background:
|
||
linear-gradient(180deg, rgba(253,251,247,0.92) 0%, rgba(247,246,242,0.98) 100%),
|
||
radial-gradient(circle at 50% 12%, rgba(232,201,210,0.2), transparent 34%);
|
||
}
|
||
.ending-frame {
|
||
position: absolute; top: 28px; right: 22px; bottom: 28px; left: 22px;
|
||
border: 1px solid rgba(168,140,107,0.26); border-radius: 160px 160px 24px 24px;
|
||
background: rgba(255,255,255,0.28); pointer-events: none;
|
||
}
|
||
.ending-frame::before {
|
||
content: ''; position: absolute; inset: 9px;
|
||
border: 0.5px solid rgba(168,140,107,0.18); border-radius: 150px 150px 18px 18px;
|
||
}
|
||
.ending-corners i {
|
||
position: absolute; width: 54px; height: 54px; border-color: rgba(168,140,107,0.28);
|
||
}
|
||
.ending-corners i:first-child { top: 44px; left: 38px; border-top: 1px solid; border-left: 1px solid; }
|
||
.ending-corners i:last-child { right: 38px; bottom: 44px; border-right: 1px solid; border-bottom: 1px solid; }
|
||
.ending-kicker {
|
||
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||
margin-bottom: 18px; color: #a88c6b; font-size: 10px; letter-spacing: 0.45em;
|
||
text-transform: uppercase; padding-left: 0.45em;
|
||
}
|
||
.ending-kicker i {
|
||
display: block; width: 34px; height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(168,140,107,0.75), transparent);
|
||
}
|
||
.ending-photo {
|
||
width: min(54vw, 188px); aspect-ratio: 3 / 4; overflow: hidden;
|
||
border-radius: 9999px 9999px 16px 16px; border: 5px solid rgba(255,255,255,0.86);
|
||
box-shadow: 0 18px 42px rgba(100,78,48,0.18), 0 0 0 1px rgba(168,140,107,0.18);
|
||
background: #F7F6F2;
|
||
}
|
||
.ending-copy {
|
||
display: flex; flex-direction: column; align-items: center; text-align: center;
|
||
margin-top: 22px; margin-bottom: 18px; padding: 0 12px;
|
||
}
|
||
.ending-title {
|
||
display: block; color: #a88c6b; font-size: 20px; line-height: 1.3;
|
||
letter-spacing: 0.28em; padding-left: 0.28em; font-weight: 300;
|
||
}
|
||
.ending-names {
|
||
display: block; margin-top: 10px; color: #3A3A3A; font-size: 14px;
|
||
letter-spacing: 0.16em; font-family: "Noto Serif SC", serif;
|
||
}
|
||
.ending-date {
|
||
display: block; margin-top: 8px; color: #8A8680; font-size: 10px;
|
||
letter-spacing: 0.28em; padding-left: 0.28em;
|
||
}
|
||
.ending-venue {
|
||
width: 100%; margin-bottom: 24px; padding: 14px 18px;
|
||
border-top: 1px solid rgba(168,140,107,0.18);
|
||
border-bottom: 1px solid rgba(168,140,107,0.18);
|
||
text-align: center; background: rgba(255,255,255,0.32);
|
||
}
|
||
.ending-hotel {
|
||
display: block; color: #3A3A3A; font-size: 13px; line-height: 1.6;
|
||
letter-spacing: 0.18em; font-weight: 500;
|
||
}
|
||
.ending-address {
|
||
display: block; max-width: 260px; margin: 7px auto 0; color: #706b64;
|
||
font-size: 11px; line-height: 1.8; letter-spacing: 0.08em; white-space: pre-wrap;
|
||
}
|
||
|
||
/* ---------- 回执 CTA 按钮 ---------- */
|
||
.rsvp-cta {
|
||
position: relative; width: 100%; padding: 16px 0; border-radius: 9999px;
|
||
border: 1px solid #c9a978; background: rgba(255,255,255,0.6);
|
||
color: #8a704f; font-size: 12px; letter-spacing: 0.28em; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||
backdrop-filter: blur(8px); transition: all .35s ease;
|
||
box-shadow: 0 6px 18px rgba(184,154,120,0.12);
|
||
}
|
||
.rsvp-cta:hover { background: #a88c6b; color: #fff; border-color: #a88c6b; box-shadow: 0 10px 26px rgba(184,154,120,0.28); }
|
||
.rsvp-cta-deco { font-size: 13px; opacity: .9; }
|
||
.rsvp-cta-line { width: 18px; height: 1px; background: currentColor; opacity: .6; }
|
||
|
||
/* ---------- 出席情况单选标签 ---------- */
|
||
.rsvp-tag {
|
||
flex: 1 1 auto; min-width: 72px; padding: 10px 12px; border-radius: 12px;
|
||
border: 1px solid rgba(168,140,107,0.35); background: #Fdfbf7; color: #6b6863;
|
||
font-size: 12px; letter-spacing: 0.1em; cursor: pointer; transition: all .25s ease;
|
||
}
|
||
.rsvp-tag:hover { border-color: #a88c6b; }
|
||
.rsvp-tag.active { background: #a88c6b; border-color: #a88c6b; color: #fff; box-shadow: 0 4px 12px rgba(168,140,107,0.25); }
|
||
|
||
/* ---------- 音乐授权模态框 ---------- */
|
||
.music-auth-card { width: 240px; background: #fff; border-radius: 22px; padding: 28px 24px 22px; text-align: center;
|
||
box-shadow: 0 24px 60px rgba(0,0,0,0.28); border: 1px solid rgba(168,140,107,0.2); }
|
||
.music-auth-note { font-size: 26px; color: #a88c6b; margin-bottom: 12px; }
|
||
.music-auth-title { font-size: 16px; color: #4a3f30; letter-spacing: 0.2em; margin-bottom: 6px; }
|
||
.music-auth-sub { font-size: 11px; color: #8A8680; letter-spacing: 0.15em; margin-bottom: 20px; }
|
||
.music-auth-btn { width: 100%; padding: 11px 0; border-radius: 9999px; background: #a88c6b; color: #fff;
|
||
font-size: 12px; letter-spacing: 0.2em; cursor: pointer; border: none; transition: background .3s; }
|
||
.music-auth-btn:hover { background: #967b5c; }
|
||
.music-auth-skip { margin-top: 10px; background: none; border: none; color: #b0a99e; font-size: 11px; letter-spacing: 0.1em; cursor: pointer; }
|
||
|
||
/* ---------- 音乐曲目弹层 ---------- */
|
||
.music-wrap { position: relative; }
|
||
.track-pop {
|
||
position: absolute; right: 0; bottom: 50px; width: 200px; background: rgba(255,255,255,0.95);
|
||
border: 1px solid rgba(168,140,107,0.25); border-radius: 14px; box-shadow: 0 12px 30px rgba(0,0,0,0.18);
|
||
padding: 8px; backdrop-filter: blur(12px); z-index: 60;
|
||
}
|
||
.track-pop-title { font-size: 10px; color: #a88c6b; letter-spacing: 0.2em; padding: 4px 8px 6px; }
|
||
.track-item { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 10px; cursor: pointer; transition: background 0.2s; }
|
||
.track-item:hover { background: rgba(168,140,107,0.1); }
|
||
.track-item.active { background: rgba(168,140,107,0.16); color: #a88c6b; }
|
||
.track-item .tname { font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.track-item .tcheck { font-size: 12px; color: #a88c6b; }
|
||
.eq { display: inline-flex; align-items: flex-end; gap: 2px; width: 14px; }
|
||
.eq i { width: 3px; background: #a88c6b; border-radius: 2px; animation: eqJump 0.9s ease-in-out infinite; }
|
||
.eq i:nth-child(1) { height: 8px; animation-delay: 0s; }
|
||
.eq i:nth-child(2) { height: 12px; animation-delay: 0.2s; }
|
||
.eq i:nth-child(3) { height: 6px; animation-delay: 0.4s; }
|
||
@keyframes eqJump { 0%,100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
|
||
|
||
@keyframes emblemIn { from { opacity: 0; transform: scale(0.85) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
|
||
@keyframes hintPulse { 0%,100% { transform: scale(1); opacity: 0.6; } 50% { transform: scale(1.7); opacity: 1; } }
|
||
</style>
|