2026-07-30 18:00:27 +08:00
< template >
< div class = "wrapper relative w-full h-[100vh] bg-[#Fdfbf7] overflow-hidden text-[#2c2c2c]" >
2026-07-31 10:29:14 +08:00
<!-- 入场揭幕帘幕 ( 雅致双开门 + 徽标 ) -- >
2026-07-31 09:05:15 +08:00
< 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 >
2026-07-31 10:29:14 +08:00
< 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 >
2026-07-31 09:05:15 +08:00
< div class = "curtain-emblem" >
2026-07-31 10:29:14 +08:00
< 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 >
2026-07-31 09:05:15 +08:00
< div class = "emblem-date" > { { data . date } } < / div >
2026-07-31 10:29:14 +08:00
< div class = "emblem-hint" > < span class = "dot" > < / span > 轻触开启请柬 < / div >
2026-07-31 09:05:15 +08:00
< / div >
2026-07-30 18:00:27 +08:00
< / div >
< / div >
2026-07-31 11:36:19 +08:00
<!-- 背景柔光层 ( 淡金 / 淡粉光斑 , 缓慢漂移 ) -- >
< div class = "bg-glow" aria -hidden = " true " > < i class = "g1" > < / i > < i class = "g2" > < / i > < i class = "g3" > < / i > < / div >
<!-- 飘落花瓣层 ( 外层位移 + 内层摇曳 , 全 transform 不触发布局 ) -- >
2026-07-30 18:00:27 +08:00
< 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"
2026-07-31 11:36:19 +08:00
: 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 }" >
2026-07-31 10:29:14 +08:00
< 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 >
2026-07-30 18:00:27 +08:00
< / div >
2026-07-31 11:36:19 +08:00
<!-- 漂浮爱心层 ( 三层景深 : 远小淡模糊 / 近大清晰 ) -- >
2026-07-30 18:00:27 +08:00
< 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"
2026-07-31 11:36:19 +08:00
: 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 >
2026-07-30 18:00:27 +08:00
< / div >
<!-- 顶部滚动进度条 -- >
< div class = "absolute top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" : style = "{ width: progress * 100 + '%' }" > < / div >
<!-- 右上角控制台 -- >
2026-07-31 09:05:15 +08:00
< 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 >
2026-07-30 18:00:27 +08:00
< / div >
2026-07-31 09:05:15 +08:00
2026-07-31 12:13:41 +08:00
< 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" / >
2026-07-30 18:00:27 +08:00
< img v-else src = "https://api.iconify.design/lucide:play.svg?color=%23a88c6b" class = "w-4 h-4 ml-0.5" / >
< / div >
< / div >
2026-07-31 11:36:19 +08:00
<!-- 主滚动区域 ( 注意 : 不要加 scroll - smooth , 它会劫持 JS 滚动赋值导致卡顿 ) -- >
2026-07-30 18:00:27 +08:00
< div ref = "scrollContainerRef"
2026-07-31 11:36:19 +08:00
class = "w-full h-full overflow-y-auto no-scrollbar relative"
2026-07-30 18:00:27 +08:00
: 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 >
2026-07-31 09:05:15 +08:00
<!-- 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)" >
2026-07-30 18:00:27 +08:00
< 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" >
2026-07-31 09:05:15 +08:00
< PhotoGallery :page = "page" / >
2026-07-30 18:00:27 +08:00
< / div >
< / div >
2026-07-31 09:05:15 +08:00
<!-- 婚礼流程 -- >
2026-07-30 18:00:27 +08:00
< 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 >
2026-07-31 09:05:15 +08:00
<!-- 尾页 -- >
2026-07-31 13:09:49 +08:00
< 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 >
2026-07-30 18:00:27 +08:00
< / div >
2026-07-31 13:09:49 +08:00
< div v-reveal = "{ variant: 'scale', delay: 100 }" class="ending-photo" >
< img :src = "endImage" class = "w-full h-full bg-[#F7F6F2] object-cover" alt = "婚礼尾页照片" / >
2026-07-30 18:00:27 +08:00
< / div >
2026-07-31 13:09:49 +08:00
< 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]" >
2026-07-31 10:29:14 +08:00
< 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 >
2026-07-30 18:00:27 +08:00
< / 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 >
2026-07-31 10:29:14 +08:00
< 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 >
2026-07-30 18:00:27 +08:00
< div v-if = "!isSubmitted" class="space-y-6" >
< div >
2026-07-31 10:29:14 +08:00
< span class = "block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest pl-1" > 姓名 < / span >
2026-07-30 18:00:27 +08:00
< 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 >
2026-07-31 10:29:14 +08:00
< 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 >
2026-07-30 18:00:27 +08:00
< / div >
< / div >
< div >
2026-07-31 10:29:14 +08:00
< span class = "block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest pl-1" > 祝福语 < / span >
2026-07-30 18:00:27 +08:00
< 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 >
2026-07-31 10:29:14 +08:00
2026-07-30 18:00:27 +08:00
< / div >
< / template >
< script setup >
2026-07-31 09:05:15 +08:00
import { ref , reactive , computed , onMounted , onUnmounted } from 'vue'
2026-07-30 18:00:27 +08:00
import { getConfig , submitRsvp } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
2026-07-31 09:05:15 +08:00
import PhotoGallery from '@/components/PhotoGallery.vue'
2026-07-30 18:00:27 +08:00
const DEFAULTS = {
2026-07-31 09:05:15 +08:00
scrollMode : 'snap' , petalEnabled : true , bubbleEnabled : true , introEnabled : true ,
musicList : [ ] , activeMusicUrl : '' ,
2026-07-31 11:36:19 +08:00
freeScrollInterval : 50 , // 自由滚动速度:滚动间隔 ms( 越小越快)
pageSwitchDuration : 800 , // 页面切换速度:翻页动画时长 ms
2026-07-30 18:00:27 +08:00
}
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' ,
2026-07-31 13:09:49 +08:00
endImg : 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80' ,
2026-07-30 18:00:27 +08:00
photoPages : [
2026-07-31 10:29:14 +08:00
{ 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' } ,
2026-07-30 18:00:27 +08:00
] ,
schedule : [ { time : '16:00' , event : '签到迎宾' , desc : 'Welcome' } , { time : '17:08' , event : '仪式开始' , desc : 'Ceremony' } , { time : '18:00' , event : '敬备喜宴' , desc : 'Banquet' } ] ,
... DEFAULTS ,
} )
const audio = useAudio ( )
2026-07-31 10:29:14 +08:00
const isAutoScroll = ref ( true ) , isDrawerOpen = ref ( false ) , isSubmitted = ref ( false )
2026-07-30 18:00:27 +08:00
const isInteracting = ref ( false ) , scrollContainerRef = ref ( null ) , progress = ref ( 0 ) , parallax = ref ( 0 )
2026-07-31 10:29:14 +08:00
const showIntro = ref ( false ) , introOpening = ref ( false ) , showTrackList = ref ( false ) , showMusicAuth = ref ( false )
2026-07-30 18:00:27 +08:00
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 : '遗憾缺席' } ]
2026-07-31 11:36:19 +08:00
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' ,
}
} )
2026-07-30 18:00:27 +08:00
2026-07-31 09:05:15 +08:00
const curtainBg = computed ( ( ) => ( { backgroundImage : ` url( ${ data . value . heroImg } ) ` } ) )
2026-07-31 13:09:49 +08:00
const endImage = computed ( ( ) => data . value . endImg || data . value . heroImg )
2026-07-31 09:05:15 +08:00
// 相册页页面高度:支持 100vh / 80vh / 60vh / auto / 自定义 vh
const pageHeightStyle = ( page ) => {
const h = page . height || '100vh'
return h === 'auto' ? { minHeight : 'auto' } : { minHeight : h }
}
2026-07-31 10:29:14 +08:00
const closeDrawer = ( ) => { isDrawerOpen . value = false }
const authorizeMusic = ( ) => { audio . play ( ) ; showMusicAuth . value = false }
2026-07-31 13:09:49 +08:00
audio . play ( )
2026-07-30 18:00:27 +08:00
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
2026-07-31 11:36:19 +08:00
// 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动
cancelAnimationFrame ( animId )
2026-07-30 18:00:27 +08:00
clearTimeout ( pauseTimer )
pauseTimer = setTimeout ( ( ) => ( isInteracting . value = false ) , 3000 )
}
const sectionEls = ( ) => Array . from ( scrollContainerRef . value ? . querySelectorAll ( '.page-section' ) || [ ] )
2026-07-31 11:36:19 +08:00
// 带时长的平滑滚动( 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 )
}
2026-07-30 18:00:27 +08:00
const goNextSection = ( ) => {
const el = scrollContainerRef . value
if ( ! el ) return
const top = el . scrollTop
const next = sectionEls ( ) . find ( ( e ) => e . offsetTop > top + 12 )
2026-07-31 11:36:19 +08:00
// 循环逻辑:滚到最后一屏后回到第一屏继续轮播
smoothScrollTo ( el , next ? next . offsetTop : 0 , switchDuration . value )
2026-07-30 18:00:27 +08:00
}
const startAutoScroll = ( ) => {
if ( data . value . scrollMode === 'snap' ) {
snapTimer = setInterval ( ( ) => {
if ( isAutoScroll . value && ! isInteracting . value && ! isDrawerOpen . value ) goNextSection ( )
} , 4500 )
} else {
2026-07-31 11:36:19 +08:00
// 自由滚动: setInterval 按后台配置的间隔匀速推进(间隔越小越快)
freeTimer = setInterval ( ( ) => {
2026-07-30 18:00:27 +08:00
const el = scrollContainerRef . value
if ( el && isAutoScroll . value && ! isInteracting . value && ! isDrawerOpen . value ) {
2026-07-31 11:36:19 +08:00
el . scrollTop += 1.2
if ( el . scrollTop >= el . scrollHeight - el . clientHeight - 5 ) el . scrollTop = 0
2026-07-30 18:00:27 +08:00
}
2026-07-31 11:36:19 +08:00
} , freeInterval . value )
2026-07-30 18:00:27 +08:00
}
}
const toggleAutoScroll = ( ) => {
isAutoScroll . value = ! isAutoScroll . value
const el = scrollContainerRef . value
if ( isAutoScroll . value && el && el . scrollTop >= el . scrollHeight - el . clientHeight - 10 ) el . scrollTop = 0
}
2026-07-31 09:05:15 +08:00
// 入场揭幕:开门动画
const openInvitation = ( ) => {
if ( introOpening . value ) return
introOpening . value = true
introTimer = setTimeout ( ( ) => { showIntro . value = false } , 1000 )
2026-07-31 10:29:14 +08:00
// 点击揭幕是明确的用户手势:直接尝试播放背景音乐(最可靠的自动播放路径)
if ( data . value . musicList . length ) audio . play ( )
}
const onMusicAuthSkip = ( ) => {
showMusicAuth . value = false
// 用户暂不开启:挂起首次交互解锁,后续任意点击仍可播放
audio . armAutoplay ( )
2026-07-31 09:05:15 +08:00
}
const selectTrack = ( url ) => { audio . setActive ( url ) ; showTrackList . value = false }
2026-07-30 18:00:27 +08:00
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
2026-07-31 09:05:15 +08:00
// 兼容旧版单链接 musicUrl
if ( loaded . musicUrl && ! ( loaded . musicList && loaded . musicList . length ) ) {
loaded . musicList = [ { url : loaded . musicUrl , name : '背景音乐' } ]
loaded . activeMusicUrl = loaded . musicUrl
}
2026-07-31 13:09:49 +08:00
loaded . endImg = loaded . endImg || loaded . heroImg || data . value . endImg
2026-07-30 18:00:27 +08:00
data . value = { ... data . value , ... DEFAULTS , ... loaded }
}
} catch ( e ) { /* 使用默认配置 */ }
2026-07-31 09:05:15 +08:00
audio . setTracks ( data . value . musicList , data . value . activeMusicUrl )
2026-07-31 10:29:14 +08:00
// 先静默尝试自动播放;被浏览器拦截(无用户手势)时,再弹出授权模态框
if ( data . value . musicList . length ) {
const autoOk = await audio . attemptAutoplay ( )
if ( ! autoOk ) showMusicAuth . value = true
}
2026-07-30 18:00:27 +08:00
if ( data . value . introEnabled ) {
showIntro . value = true
2026-07-31 09:05:15 +08:00
introTimer = setTimeout ( ( ) => openInvitation ( ) , 3600 )
2026-07-30 18:00:27 +08:00
}
startAutoScroll ( )
handleScroll ( )
} )
onUnmounted ( ( ) => {
cancelAnimationFrame ( rafId )
2026-07-31 11:36:19 +08:00
cancelAnimationFrame ( animId )
2026-07-30 18:00:27 +08:00
clearInterval ( snapTimer )
2026-07-31 11:36:19 +08:00
clearInterval ( freeTimer )
2026-07-30 18:00:27 +08:00
clearTimeout ( pauseTimer )
clearTimeout ( introTimer )
} )
< / script >
< style scoped >
. wrapper { font - family : "PingFang SC" , sans - serif ; }
. control - btn {
width : 38 px ; height : 38 px ; display : flex ; align - items : center ; justify - content : center ; border - radius : 9999 px ;
backdrop - filter : blur ( 12 px ) ; border : 0.5 px 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.4 em ; text - orientation : upright ; }
. page - section { min - height : 100 vh ; width : 100 % ; position : relative ; overflow : hidden ; display : flex ; flex - direction : column ; justify - content : center ; }
. no - scrollbar : : - webkit - scrollbar { display : none ; }
2026-07-31 10:29:14 +08:00
/* 花瓣形状由 SVG 决定,无需额外形状样式 */
2026-07-31 09:05:15 +08:00
2026-07-31 10:29:14 +08:00
/* ---------- 入场揭幕(雅致象牙金) ---------- */
. curtain - root { position : fixed ; inset : 0 ; z - index : 100 ; overflow : hidden ; cursor : pointer ; font - family : "PingFang SC" , sans - serif ; background : # fbf6ef ; }
2026-07-31 09:05:15 +08:00
. curtain - bg {
position : absolute ; inset : - 24 px ; background - size : cover ; background - position : center ;
2026-07-31 10:29:14 +08:00
filter : blur ( 16 px ) brightness ( 0.7 ) saturate ( 1.05 ) ; transform : scale ( 1.12 ) ;
2026-07-31 09:05:15 +08:00
}
2026-07-31 10:29:14 +08:00
. curtain - veil { position : absolute ; inset : 0 ; background : linear - gradient ( 180 deg , rgba ( 251 , 246 , 239 , 0.35 ) , rgba ( 232 , 214 , 194 , 0.25 ) ) ; }
2026-07-31 09:05:15 +08:00
. curtain - door {
position : absolute ; top : 0 ; bottom : 0 ; width : 50 % ; z - index : 2 ;
2026-07-31 10:29:14 +08:00
background : linear - gradient ( 160 deg , # fbf6ef 0 % , # f5e9dc 45 % , # ecd9c4 100 % ) ;
box - shadow : inset 0 0 50 px rgba ( 184 , 154 , 120 , 0.12 ) ;
transition : transform 1.4 s cubic - bezier ( 0.76 , 0 , 0.24 , 1 ) ;
2026-07-31 09:05:15 +08:00
}
2026-07-31 10:29:14 +08:00
. curtain - door : : before { content : '' ; position : absolute ; top : 0 ; bottom : 0 ; width : 1 px ; background : linear - gradient ( 180 deg , transparent , rgba ( 184 , 154 , 120 , 0.5 ) , transparent ) ; }
2026-07-31 09:05:15 +08:00
. curtain - door - left { left : 0 ; }
. curtain - door - right { right : 0 ; }
2026-07-31 10:29:14 +08:00
. curtain - door - left : : before { right : 0 ; }
. curtain - door - right : : before { left : 0 ; }
. door - sheen { position : absolute ; inset : 0 ; background : linear - gradient ( 115 deg , 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 % ) ; }
2026-07-31 09:05:15 +08:00
. 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 ;
2026-07-31 10:29:14 +08:00
transition : opacity 0.8 s ease , transform 0.9 s ease ; animation : emblemIn 1.1 s ease both ;
2026-07-31 09:05:15 +08:00
}
2026-07-31 10:29:14 +08:00
. curtain - root . is - opening . curtain - emblem { opacity : 0 ; transform : scale ( 1.25 ) ; }
. emblem - inner { text - align : center ; color : # 5 b4a36 ; padding : 10 px ; }
. emblem - floral { display : block ; font - size : 22 px ; color : # b89a78 ; margin - bottom : 14 px ; }
. emblem - kicker { display : block ; letter - spacing : 0.6 em ; font - size : 9 px ; color : # b89a78 ; text - transform : uppercase ; margin - bottom : 18 px ; padding - left : 0.6 em ; }
. emblem - names { font - family : "Noto Serif SC" , serif ; font - size : 30 px ; letter - spacing : 0.18 em ; font - weight : 300 ; color : # 4 a3f30 ; }
. emblem - names . amp { font - style : italic ; color : # b89a78 ; margin : 0 8 px ; }
. emblem - rule { width : 46 px ; height : 1 px ; background : linear - gradient ( 90 deg , transparent , # b89a78 , transparent ) ; margin : 18 px auto ; }
. emblem - date { font - size : 11 px ; letter - spacing : 0.3 em ; color : # 8 a7458 ; }
. emblem - hint { margin - top : 26 px ; font - size : 11 px ; letter - spacing : 0.3 em ; color : # a08a66 ;
2026-07-31 09:05:15 +08:00
display : flex ; align - items : center ; justify - content : center ; gap : 8 px ; }
2026-07-31 10:29:14 +08:00
. emblem - hint . dot { width : 6 px ; height : 6 px ; border - radius : 9999 px ; background : # b89a78 ; animation : hintPulse 1.6 s infinite ; }
2026-07-31 13:09:49 +08:00
/* ---------- 尾页 ---------- */
. ending - page {
background :
linear - gradient ( 180 deg , 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 : 28 px ; right : 22 px ; bottom : 28 px ; left : 22 px ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.26 ) ; border - radius : 160 px 160 px 24 px 24 px ;
background : rgba ( 255 , 255 , 255 , 0.28 ) ; pointer - events : none ;
}
. ending - frame : : before {
content : '' ; position : absolute ; inset : 9 px ;
border : 0.5 px solid rgba ( 168 , 140 , 107 , 0.18 ) ; border - radius : 150 px 150 px 18 px 18 px ;
}
. ending - corners i {
position : absolute ; width : 54 px ; height : 54 px ; border - color : rgba ( 168 , 140 , 107 , 0.28 ) ;
}
. ending - corners i : first - child { top : 44 px ; left : 38 px ; border - top : 1 px solid ; border - left : 1 px solid ; }
. ending - corners i : last - child { right : 38 px ; bottom : 44 px ; border - right : 1 px solid ; border - bottom : 1 px solid ; }
. ending - kicker {
display : flex ; flex - direction : column ; align - items : center ; gap : 10 px ;
margin - bottom : 18 px ; color : # a88c6b ; font - size : 10 px ; letter - spacing : 0.45 em ;
text - transform : uppercase ; padding - left : 0.45 em ;
}
. ending - kicker i {
display : block ; width : 34 px ; height : 1 px ;
background : linear - gradient ( 90 deg , transparent , rgba ( 168 , 140 , 107 , 0.75 ) , transparent ) ;
}
. ending - photo {
width : min ( 54 vw , 188 px ) ; aspect - ratio : 3 / 4 ; overflow : hidden ;
border - radius : 9999 px 9999 px 16 px 16 px ; border : 5 px solid rgba ( 255 , 255 , 255 , 0.86 ) ;
box - shadow : 0 18 px 42 px rgba ( 100 , 78 , 48 , 0.18 ) , 0 0 0 1 px rgba ( 168 , 140 , 107 , 0.18 ) ;
background : # F7F6F2 ;
}
. ending - copy {
display : flex ; flex - direction : column ; align - items : center ; text - align : center ;
margin - top : 22 px ; margin - bottom : 18 px ; padding : 0 12 px ;
}
. ending - title {
display : block ; color : # a88c6b ; font - size : 20 px ; line - height : 1.3 ;
letter - spacing : 0.28 em ; padding - left : 0.28 em ; font - weight : 300 ;
}
. ending - names {
display : block ; margin - top : 10 px ; color : # 3 A3A3A ; font - size : 14 px ;
letter - spacing : 0.16 em ; font - family : "Noto Serif SC" , serif ;
}
. ending - date {
display : block ; margin - top : 8 px ; color : # 8 A8680 ; font - size : 10 px ;
letter - spacing : 0.28 em ; padding - left : 0.28 em ;
}
. ending - venue {
width : 100 % ; margin - bottom : 24 px ; padding : 14 px 18 px ;
border - top : 1 px solid rgba ( 168 , 140 , 107 , 0.18 ) ;
border - bottom : 1 px solid rgba ( 168 , 140 , 107 , 0.18 ) ;
text - align : center ; background : rgba ( 255 , 255 , 255 , 0.32 ) ;
}
. ending - hotel {
display : block ; color : # 3 A3A3A ; font - size : 13 px ; line - height : 1.6 ;
letter - spacing : 0.18 em ; font - weight : 500 ;
}
. ending - address {
display : block ; max - width : 260 px ; margin : 7 px auto 0 ; color : # 706 b64 ;
font - size : 11 px ; line - height : 1.8 ; letter - spacing : 0.08 em ; white - space : pre - wrap ;
}
2026-07-31 10:29:14 +08:00
/* ---------- 回执 CTA 按钮 ---------- */
. rsvp - cta {
position : relative ; width : 100 % ; padding : 16 px 0 ; border - radius : 9999 px ;
border : 1 px solid # c9a978 ; background : rgba ( 255 , 255 , 255 , 0.6 ) ;
color : # 8 a704f ; font - size : 12 px ; letter - spacing : 0.28 em ; cursor : pointer ;
display : flex ; align - items : center ; justify - content : center ; gap : 10 px ;
backdrop - filter : blur ( 8 px ) ; transition : all .35 s ease ;
box - shadow : 0 6 px 18 px rgba ( 184 , 154 , 120 , 0.12 ) ;
}
. rsvp - cta : hover { background : # a88c6b ; color : # fff ; border - color : # a88c6b ; box - shadow : 0 10 px 26 px rgba ( 184 , 154 , 120 , 0.28 ) ; }
. rsvp - cta - deco { font - size : 13 px ; opacity : .9 ; }
. rsvp - cta - line { width : 18 px ; height : 1 px ; background : currentColor ; opacity : .6 ; }
/* ---------- 出席情况单选标签 ---------- */
. rsvp - tag {
flex : 1 1 auto ; min - width : 72 px ; padding : 10 px 12 px ; border - radius : 12 px ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.35 ) ; background : # Fdfbf7 ; color : # 6 b6863 ;
font - size : 12 px ; letter - spacing : 0.1 em ; cursor : pointer ; transition : all .25 s ease ;
}
. rsvp - tag : hover { border - color : # a88c6b ; }
. rsvp - tag . active { background : # a88c6b ; border - color : # a88c6b ; color : # fff ; box - shadow : 0 4 px 12 px rgba ( 168 , 140 , 107 , 0.25 ) ; }
/* ---------- 音乐授权模态框 ---------- */
. music - auth - card { width : 240 px ; background : # fff ; border - radius : 22 px ; padding : 28 px 24 px 22 px ; text - align : center ;
box - shadow : 0 24 px 60 px rgba ( 0 , 0 , 0 , 0.28 ) ; border : 1 px solid rgba ( 168 , 140 , 107 , 0.2 ) ; }
. music - auth - note { font - size : 26 px ; color : # a88c6b ; margin - bottom : 12 px ; }
. music - auth - title { font - size : 16 px ; color : # 4 a3f30 ; letter - spacing : 0.2 em ; margin - bottom : 6 px ; }
. music - auth - sub { font - size : 11 px ; color : # 8 A8680 ; letter - spacing : 0.15 em ; margin - bottom : 20 px ; }
. music - auth - btn { width : 100 % ; padding : 11 px 0 ; border - radius : 9999 px ; background : # a88c6b ; color : # fff ;
font - size : 12 px ; letter - spacing : 0.2 em ; cursor : pointer ; border : none ; transition : background .3 s ; }
. music - auth - btn : hover { background : # 967 b5c ; }
. music - auth - skip { margin - top : 10 px ; background : none ; border : none ; color : # b0a99e ; font - size : 11 px ; letter - spacing : 0.1 em ; cursor : pointer ; }
2026-07-31 09:05:15 +08:00
/* ---------- 音乐曲目弹层 ---------- */
. music - wrap { position : relative ; }
. track - pop {
position : absolute ; right : 0 ; bottom : 50 px ; width : 200 px ; background : rgba ( 255 , 255 , 255 , 0.95 ) ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.25 ) ; border - radius : 14 px ; box - shadow : 0 12 px 30 px rgba ( 0 , 0 , 0 , 0.18 ) ;
padding : 8 px ; backdrop - filter : blur ( 12 px ) ; z - index : 60 ;
}
. track - pop - title { font - size : 10 px ; color : # a88c6b ; letter - spacing : 0.2 em ; padding : 4 px 8 px 6 px ; }
. track - item { display : flex ; align - items : center ; gap : 8 px ; padding : 8 px ; border - radius : 10 px ; cursor : pointer ; transition : background 0.2 s ; }
. 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 : 12 px ; flex : 1 ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. track - item . tcheck { font - size : 12 px ; color : # a88c6b ; }
. eq { display : inline - flex ; align - items : flex - end ; gap : 2 px ; width : 14 px ; }
. eq i { width : 3 px ; background : # a88c6b ; border - radius : 2 px ; animation : eqJump 0.9 s ease - in - out infinite ; }
. eq i : nth - child ( 1 ) { height : 8 px ; animation - delay : 0 s ; }
. eq i : nth - child ( 2 ) { height : 12 px ; animation - delay : 0.2 s ; }
. eq i : nth - child ( 3 ) { height : 6 px ; animation - delay : 0.4 s ; }
@ keyframes eqJump { 0 % , 100 % { transform : scaleY ( 0.4 ) ; } 50 % { transform : scaleY ( 1 ) ; } }
@ keyframes emblemIn { from { opacity : 0 ; transform : scale ( 0.85 ) translateY ( 10 px ) ; } 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 ; } }
2026-07-30 18:00:27 +08:00
< / style >