Files
hunli/vite-tailwindcss/src/views/index/index.vue
2026-07-31 13:09:49 +08:00

634 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">&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">&amp;</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 }} &amp; {{ 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') || [])
// 带时长的平滑滚动easeInOutQuadduration 由后台 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>