Files
hunli/vite-tailwindcss/src/views/index/index.vue
2026-08-03 11:04:03 +08:00

2952 lines
117 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 min-h-[100dvh] bg-[#Fdfbf7] overflow-x-hidden text-[#2c2c2c]">
<!-- 入场揭幕帘幕(请柬封面) -->
<div v-show="showIntro" class="curtain-root" :class="[{ 'is-opening': introOpening }, introExitClass]" @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="invite-cover">
<span class="cover-filigree cover-filigree-top" aria-hidden="true"></span>
<span class="cover-filigree cover-filigree-bottom" aria-hidden="true"></span>
<div class="emblem-inner">
<span class="emblem-kicker first-letter:uppercase lowercase calligraphy">WEDDING INVITATION</span>
<div class="emblem-seal" aria-hidden="true">
<i class="fa-solid fa-feather-pointed"></i>
</div>
<div class="emblem-names calligraphy-strong">{{ data.groom }}<span class="amp">&amp;</span>{{ data.bride }}</div>
<div class="emblem-rule"></div>
<div class="emblem-date calligraphy">{{ data.date }}</div>
<div class="emblem-invite-title calligraphy-strong">岁序更替</div>
<div class="emblem-invite-copy calligraphy">始于初见,止于终老,时光煮雨,人事静好,盼与您分享我们的幸福时刻。</div>
<div class="emblem-hint"><span class="dot"></span>轻触开启请柬</div>
</div>
</div>
</div>
</div>
<!-- 背景柔光层(淡金/淡粉光斑,缓慢漂移) -->
<div class="bg-glow" aria-hidden="true"><i class="g1"></i><i class="g2"></i><i class="g3"></i></div>
<CanvasEffects
:petal-enabled="data.petalEnabled"
:bubble-enabled="data.bubbleEnabled"
:active="effectsActive"
/>
<DanmakuLayer
ref="danmakuLayerRef"
:enabled="!!data.danmakuEnabled"
:show-time="!!data.danmakuShowTime"
:active="effectsActive"
/>
<!-- 顶部滚动进度条(transform 驱动,避免 width/left 强制重排) -->
<div
class="scroll-progress"
aria-hidden="true"
:style="{ '--scroll-p': String(Math.min(1, Math.max(0, progress))) }"
>
<div class="scroll-progress-track" />
<div class="scroll-progress-fill" />
<span class="scroll-progress-xi calligraphy">囍</span>
</div>
<!-- 右上角:自动滚动 -->
<div class="fixed top-6 right-5 z-50 flex flex-col items-end gap-4">
<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>
<!-- 底部互动区 -->
<div
class="action-dock"
@touchstart.stop
@touchmove.stop
@touchend.stop
@mousedown.stop
@click.stop
>
<div class="like-side-shield" aria-hidden="true" />
<!-- 行1:点赞 -->
<div class="action-dock-row">
<div class="like-hit-zone">
<button
type="button"
class="action-pill like-fab"
:class="{ liked: likePulse, busy: likeBusy }"
:disabled="likeBusy"
title="点赞"
@click="handleLike"
>
<i class="fa-heart like-icon" :class="likePulse ? 'fa-solid' : 'fa-regular'"></i>
<span class="like-count">{{ likeCount }}</span>
</button>
</div>
</div>
<!-- 行2:导航 + 回执 -->
<div class="action-dock-row">
<button
type="button"
class="action-pill"
title="导航"
@click="showTrackList = false; openNavigation()"
>
<img src="https://api.iconify.design/lucide:map-pinned.svg?color=%23a88c6b" class="action-pill-icon" alt="" />
</button>
<button
type="button"
class="action-pill action-rsvp"
title="出席回执"
@click="showTrackList = false; isDrawerOpen = true"
>
<img src="https://api.iconify.design/lucide:clipboard-pen.svg?color=%23a88c6b" class="action-pill-icon" alt="" />
</button>
</div>
<!-- 行3:黑胶 + 评论 -->
<div class="action-comment-row">
<div class="vinyl-wrap" v-if="audio.tracks.value.length">
<button
type="button"
class="vinyl-disc"
:class="{ spinning: audio.isPlaying.value }"
title="打开播放器"
@click="openPlayerPanel"
>
<span class="vinyl-rim" aria-hidden="true" />
<span class="vinyl-grooves" aria-hidden="true" />
<span class="vinyl-sheen" aria-hidden="true" />
<span class="vinyl-label-ring" aria-hidden="true" />
<span
class="vinyl-label"
:style="audio.currentCover.value ? { backgroundImage: `url(${audio.currentCover.value})` } : undefined"
/>
<span class="vinyl-spindle" aria-hidden="true" />
</button>
<button
type="button"
class="vinyl-play-fab"
:title="audio.isPlaying.value ? '暂停' : '播放'"
@click.stop="audio.toggle()"
>
<i :class="audio.isPlaying.value ? 'fa-solid fa-pause' : 'fa-solid fa-play'" />
</button>
<div v-if="showPlayerPanel" class="player-panel">
<span class="player-panel-arrow" aria-hidden="true" />
<div class="player-panel-head">
<div class="player-panel-meta">
<span
v-if="audio.currentCover.value"
class="player-panel-cover"
:style="{ backgroundImage: `url(${audio.currentCover.value})` }"
/>
<span class="player-panel-title">{{ audio.currentName.value }}</span>
</div>
<button type="button" class="player-panel-close" @click="showPlayerPanel = false">✕</button>
</div>
<!-- 上:歌词 -->
<div ref="lrcListRef" class="player-lrc-list">
<template v-if="lrcLines.length">
<p
v-for="(line, i) in lrcLines"
:key="`${line.time}-${i}`"
class="player-lrc-line"
:class="{ active: i === activeLrcIndex }"
>{{ line.text }}</p>
</template>
<p v-else class="player-lrc-empty">暂无歌词</p>
</div>
<!-- 下:左 上/播/下,右 列表+音量 / 进度 -->
<div class="player-panel-footer">
<div class="player-transport">
<button type="button" class="player-skip-btn" title="上一首" :disabled="audio.tracks.value.length < 2" @click="playPrevTrack">
<i class="fa-solid fa-backward-step" />
</button>
<button type="button" class="player-play-btn" @click="audio.toggle()">
<i :class="audio.isPlaying.value ? 'fa-solid fa-pause' : 'fa-solid fa-play'" />
</button>
<button type="button" class="player-skip-btn" title="下一首" :disabled="audio.tracks.value.length < 2" @click="playNextTrack">
<i class="fa-solid fa-forward-step" />
</button>
</div>
<div class="player-panel-sliders">
<div class="player-vol-row">
<div class="player-list-wrap">
<button
v-if="audio.tracks.value.length > 1"
type="button"
class="player-vol-icon"
title="曲目列表"
@click="showPanelTrackList = !showPanelTrackList; showVolumeSlider = false"
>
<i class="fa-solid fa-list-ul" />
</button>
<div v-if="showPanelTrackList && audio.tracks.value.length" class="player-track-pop">
<div class="player-track-pop-title">选择曲目</div>
<div class="player-track-pop-list">
<button
v-for="t in audio.tracks.value"
:key="t.url"
type="button"
class="player-track-item"
:class="{ active: t.url === audio.activeUrl.value }"
@click="selectTrack(t.url)"
>
<span class="tname">{{ t.name }}</span>
<span v-if="t.url === audio.activeUrl.value" class="tcheck">✓</span>
</button>
</div>
</div>
</div>
<button type="button" class="player-vol-icon" title="音量" @click="toggleVolumeSlider">
<i :class="volumeIconClass" />
</button>
<div v-if="showVolumeSlider" class="player-vol-track">
<input
type="range"
class="player-vol-range"
min="0"
max="1"
step="0.01"
:value="audio.volume.value"
:style="{ '--vol': `${Math.round((audio.volume.value || 0) * 100)}%` }"
@input="onVolumeInput"
/>
</div>
</div>
<div class="player-seek-wrap">
<input
type="range"
class="player-range"
min="0"
:max="seekMax"
step="0.1"
:value="audio.currentTime.value"
:style="{ '--seek': seekPercent }"
@input="onSeekInput"
/>
<div class="player-time">
<span>{{ formatAudioTime(audio.currentTime.value) }}</span>
<span>{{ formatAudioTime(audio.duration.value) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<button
v-if="data.danmakuEnabled"
type="button"
class="action-comment"
title="送祝福"
@click="showTrackList = false; openBlessingDrawer()"
>
<img src="https://api.iconify.design/lucide:message-circle-heart.svg?color=%23a88c6b" class="action-comment-icon" alt="" />
<span>写一句祝福</span>
</button>
</div>
</div>
<LikeBurst ref="likeBurstRef" />
<!-- 主滚动区域(注意:不要加 scroll-smooth,它会劫持 JS 滚动赋值导致卡顿) -->
<!-- iOS 自由自动滚时对该节点做 translate3d,避免每帧写 scrollTop 与系统合成器打架 -->
<div ref="scrollContainerRef"
class="w-full min-h-[100dvh] no-scrollbar relative scroll-transform-host"
@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="cover-kicker calligraphy text-[10px] tracking-[0.5em] uppercase text-[#a88c6b] font-bold 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="optimizeImageUrl(data.heroImg)" class="w-full h-full object-cover" fetchpriority="high" decoding="async" alt="封面" />
</div>
</div>
<div v-reveal="{ variant: 'up', delay: 200 }" class="text-center z-10 mb-10 space-y-4 px-4 mt-8">
<div class="cover-names calligraphy-strong 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 calligraphy">&amp;</span>
<span>{{ data.bride }}</span>
</div>
<div class="cover-date 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' : ''" :style="formalPageHeightStyle">
<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-12 text-[#a88c6b] font-serif float-soft">❀</div>
<div class="formal-copy flex justify-center gap-9 h-[22rem] text-[#3A3A3A]">
<span class="vertical-text formal-main calligraphy-strong">{{ data.formalText1 }}</span>
<span class="vertical-text formal-main formal-main-offset calligraphy-strong">{{ data.formalText2 }}</span>
<span class="vertical-text formal-side calligraphy ml-4 border-l-[0.5px] border-[#a88c6b]/30 pl-6 h-64 mt-28 tracking-[0.28em]">见证我们的幸福</span>
<span class="vertical-text formal-side calligraphy ml-4 h-64 mt-8 tracking-[0.28em]">诚挚邀请您</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 -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"
:nine-grid-animate="!!data.nineGridAnimate"
@preview-image="openImagePreview"
@ready="onGalleryReady"
/>
</div>
</div>
<!-- 婚礼日历 -->
<div class="page-section wedding-calendar-page px-6 py-10 relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div v-reveal="'up'" class="calendar-card">
<div class="calendar-kicker">Save The Date</div>
<div class="calendar-title">
<span>{{ calendarTitle.year }}</span>
<i></i>
<span>{{ calendarTitle.monthText }}</span>
</div>
<div class="calendar-date-line">{{ data.groom }} &amp; {{ data.bride }} · {{ weddingDateText }}</div>
<div class="calendar-weekdays">
<span v-for="w in calendarWeekdays" :key="w">{{ w }}</span>
</div>
<div class="calendar-grid">
<span
v-for="(day, idx) in calendarDays"
:key="idx"
class="calendar-day"
:class="{ 'is-empty': !day, 'is-selected': day === weddingDay }"
>
<span v-if="day" class="calendar-day-num">{{ day }}</span>
<span v-if="day === weddingDay" class="heart-ring" aria-hidden="true">
<svg viewBox="0 0 120 96" role="img">
<defs>
<filter id="calendar-heart-rough" x="-18%" y="-18%" width="136%" height="136%">
<feTurbulence type="fractalNoise" baseFrequency="0.045" numOctaves="3" seed="11" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.8" />
</filter>
</defs>
<g filter="url(#calendar-heart-rough)">
<path class="heart-brush heart-brush-shadow" d="M54 82 C31 70 12 50 13 29 C14 10 35 7 49 27 C56 37 58 49 59 56 C64 37 80 10 99 20 C113 28 105 51 90 66 C80 76 70 81 64 83" />
<path class="heart-brush heart-brush-main" d="M54 82 C31 70 12 50 13 29 C14 10 35 7 49 27 C56 37 58 49 59 56 C64 37 80 10 99 20 C113 28 105 51 90 66 C80 76 70 81 64 83" />
<path class="heart-brush heart-brush-tail" d="M94 61 C87 62 82 58 78 52" />
</g>
<g class="heart-brush-cuts">
<path d="M24 28 C34 15 45 23 51 39" />
<path d="M70 43 C78 25 91 22 101 30" />
<path d="M28 48 C39 59 47 65 55 70" />
<path d="M78 68 C86 64 94 57 98 49" />
</g>
<g class="heart-brush-speckles">
<circle cx="14" cy="44" r="1.9" />
<circle cx="22" cy="19" r="1.2" />
<circle cx="101" cy="22" r="1.8" />
<circle cx="109" cy="48" r="1.4" />
<circle cx="74" cy="77" r="1.2" />
</g>
</svg>
</span>
</span>
</div>
<div class="calendar-note">
<span>良辰已定</span>
<i></i>
<span>敬候光临</span>
</div>
</div>
</div>
<!-- 婚礼流程 -->
<div class="page-section schedule-page px-6 z-10 justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div v-reveal="'up'" class="schedule-heading">
<span class="schedule-title calligraphy">婚礼流程</span>
<span class="schedule-kicker">Wedding Schedule</span>
</div>
<div class="schedule-list">
<div v-for="(item, index) in data.schedule" :key="index"
v-reveal="{ variant: 'up', delay: index * 120 }"
class="schedule-row">
<div class="schedule-time">{{ item.time }}</div>
<div class="schedule-axis">
<span class="schedule-node"><i></i></span>
</div>
<div class="schedule-copy">
<span class="schedule-event">{{ item.event }}</span>
<span class="schedule-desc">{{ 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" loading="lazy" decoding="async" alt="婚礼尾页照片" />
</div>
<div v-reveal="{ variant: 'up', delay: 180 }" class="ending-copy">
<span class="ending-title calligraphy">期待您的光临</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="ending-actions 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>
<button class="map-cta mb-18" type="button" @click="openNavigation">
<span class="map-cta-icon"><i class="fa-solid fa-location-dot"></i></span>
<span class="map-cta-copy">
<b>查看酒店位置</b>
<small>{{ data.hotel }}</small>
</span>
<span class="map-cta-arrow">导航</span>
</button>
</div>
</div>
</div>
</div>
<!-- RSVP 抽屉 -->
<div v-if="isDrawerOpen" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm transition-opacity duration-500" @click="closeDrawer"></div>
<div
class="fixed 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"
:class="{ 'pointer-events-none': !isDrawerOpen }"
: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 class="relative">
<div class="flex items-center justify-between mb-1.5 pl-1 pr-0.5">
<span class="text-[10px] text-[#a88c6b] tracking-widest">祝福语</span>
<div class="ai-bless-wrap">
<button type="button" class="ai-bless-btn" :disabled="aiBlessingLoading" @click.stop="toggleAiStyle('rsvp')">
{{ aiBlessingLoading && aiBlessingTarget === 'rsvp' ? '生成中…' : 'AI 生成祝福' }}
</button>
<div v-if="aiStyleOpen === 'rsvp'" class="ai-style-card" @click.stop>
<p class="ai-style-title">选择文风</p>
<button
v-for="s in blessingStyles"
:key="s.key"
type="button"
class="ai-style-item"
@click="pickAiStyle('rsvp', s.key)"
>{{ s.label }}</button>
</div>
</div>
</div>
<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>
<button class="map-cta mt-8 w-full max-w-[280px]" type="button" @click="openNavigation">
<span class="map-cta-icon"><i class="fa-solid fa-location-dot"></i></span>
<span class="map-cta-copy">
<b>查看酒店位置</b>
<small>{{ data.hotel }}</small>
</span>
<span class="map-cta-arrow">导航</span>
</button>
</div>
</div>
<div v-if="showMapOptions" class="fixed inset-0 z-[80] bg-black/35 backdrop-blur-sm flex items-end justify-center px-4 pb-5" @click.self="showMapOptions = false">
<div class="map-sheet">
<div class="map-sheet-title">选择导航方式</div>
<button type="button" @click="openMap('amap')"><i class="fa-solid fa-location-arrow"></i>高德地图</button>
<button type="button" @click="openMap('baidu')"><i class="fa-solid fa-route"></i>百度地图</button>
<button type="button" @click="openMap('tencent')"><i class="fa-solid fa-map-location-dot"></i>腾讯地图</button>
<button type="button" class="map-sheet-cancel" @click="showMapOptions = false">取消</button>
</div>
</div>
<div v-if="showOpenInBrowser" class="open-browser-mask" @click="showOpenInBrowser = false">
<div class="open-browser-arrow" aria-hidden="true">
<svg viewBox="0 0 80 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40 100 C40 70 55 55 68 28" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M56 34 L70 22 L62 42" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
<div class="open-browser-copy">
<p class="open-browser-title">请点击右上角 ···</p>
<p class="open-browser-desc">选择「在浏览器打开」后再导航</p>
<p class="open-browser-hint">轻触屏幕关闭</p>
</div>
</div>
<!-- 送祝福抽屉 -->
<div v-if="showBlessingDrawer" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm" @click="closeBlessingDrawer"></div>
<div
class="fixed 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"
:class="{ 'pointer-events-none': !showBlessingDrawer }"
:style="{ transform: showBlessingDrawer ? 'translateY(0)' : 'translateY(100%)' }"
>
<div @click="closeBlessingDrawer" 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-8 tracking-[0.4em]">即 时 展 示</span>
<div v-if="!blessingSubmitted" class="space-y-6">
<div>
<span class="block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest pl-1">姓名</span>
<input v-model="blessingForm.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]" />
</div>
<div class="relative">
<div class="flex items-center justify-between mb-1.5 pl-1 pr-0.5">
<span class="text-[10px] text-[#a88c6b] tracking-widest">祝福</span>
<div class="ai-bless-wrap">
<button type="button" class="ai-bless-btn" :disabled="aiBlessingLoading" @click.stop="toggleAiStyle('blessing')">
{{ aiBlessingLoading && aiBlessingTarget === 'blessing' ? '生成中…' : 'AI 生成祝福' }}
</button>
<div v-if="aiStyleOpen === 'blessing'" class="ai-style-card" @click.stop>
<p class="ai-style-title">选择文风</p>
<button
v-for="s in blessingStyles"
:key="s.key"
type="button"
class="ai-style-item"
@click="pickAiStyle('blessing', s.key)"
>{{ s.label }}</button>
</div>
</div>
</div>
<textarea v-model="blessingForm.content" 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"></textarea>
</div>
<div>
<span class="block text-[10px] text-[#a88c6b] mb-2 tracking-widest pl-1">弹幕颜色</span>
<div class="danmaku-color-row">
<button
v-for="c in danmakuColorOptions"
:key="c.key"
type="button"
class="danmaku-color-dot"
:class="{ active: blessingForm.color === c.key }"
:style="{ background: c.hex }"
:title="c.label"
@click="blessingForm.color = c.key"
/>
</div>
<span class="block text-[10px] text-[#a88c6b] mb-2 mt-3 tracking-widest pl-1">渐变配色</span>
<div class="danmaku-color-row">
<button
v-for="c in danmakuGradientOptions"
:key="c.key"
type="button"
class="danmaku-color-dot danmaku-color-dot--grad"
:class="{ active: blessingForm.color === c.key }"
:style="{ background: c.gradient }"
:title="c.label"
@click="blessingForm.color = c.key"
/>
</div>
</div>
<div @click="handleSubmitBlessing" 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-8 flex flex-col items-center">
<span class="text-3xl text-[#a88c6b] mb-5">♥</span>
<span class="text-lg text-[#2c2c2c] mb-2 tracking-[0.2em] font-light">祝福已送达</span>
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light mb-8">已展示在弹幕中</span>
<button
type="button"
class="w-full max-w-[280px] bg-[#a88c6b] text-white py-4 rounded-full tracking-[0.3em] text-[12px] hover:bg-[#967b5c] transition-all shadow-md"
@click="closeBlessingDrawer"
>确认</button>
<span class="mt-4 text-[11px] text-[#8A8680] tracking-widest">{{ blessingCloseLeft }} 秒后自动关闭</span>
</div>
</div>
<!-- 注意:不可对子 a-image 设 preview=false,否则不会注册进 Group,灯箱 src 为空 -->
<a-image-preview-group :preview="albumPreviewOptions">
<div class="album-preview-registry" aria-hidden="true">
<a-image v-for="(src, idx) in albumPreviewImages" :key="`${src}-${idx}`" :src="src" />
</div>
</a-image-preview-group>
</div>
</template>
<script setup>
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
import { toast } from '@/composables/useToast'
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
import { getClientId } from '@/utils/clientId'
import { sendVisitBeacon } from '@/utils/visitBeacon'
import { optimizeImageUrl, isIOS } from '@/composables/useAndroidOptimize'
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
import CanvasEffects from '@/components/CanvasEffects.vue'
import DanmakuLayer from '@/components/DanmakuLayer.vue'
import LikeBurst from '@/components/LikeBurst.vue'
import PhotoGallery from '@/components/PhotoGallery.vue'
const DEFAULTS = {
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
musicList: [], activeMusicUrl: '', musicRandomEnabled: false,
danmakuEnabled: true, danmakuShowTime: true, nineGridAnimate: false,
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
firstScreenDuration: 4500, // 首屏停留时间:首屏效果结束后再开始自动滚动
formalPageHeight: '100dvh',
calendarDate: '2026-09-04',
introExitEffect: 'wind',
}
const data = ref({"date": "2026年9月4日 星期五", "bride": "李逸烁", "groom": "李琦", "hotel": "芙蓉酒楼太行店", "lunar": "农历七月廿三", "endImg": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/132145000000000_1774761363179_compressed.webp", "address": "阳泉市芙蓉酒楼太行店 二楼", "heroImg": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/130719000000000_1774761309141_compressed.webp", "musicUrl": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", "schedule": [{"desc": "Welcome Guests", "time": "11:00", "event": "签到迎宾"}, {"desc": "Wedding Ceremony", "time": "12:00", "event": "婚礼仪式"}, {"desc": "Wedding Banquet", "time": "12:30", "event": "敬备喜宴"}], "musicList": [{"url": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", "name": "背景音乐"}, {"url": "http://localhost:8080/uploads/1785462317_4112586452.mp3", "name": "梦中的婚礼"}, {"url": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/165608000000000_M500001mh1Wp3Qq0BG.mp3", "name": "M500001mh1Wp3Qq0BG"}], "photoPages": [{"desc": "世界那么大,还是遇见了你。\n于千万人之中,没有早一步也没有晚一步。", "img1": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/163856000000000_xwatermark.com_20260731163822453.jpg", "type": "single", "title": "初遇", "height": "80vh", "subtitle": "FIRST MET", "borderStyle": "gold", "singleWidth": "default", "singleKeepRatio": true}, {"desc": "纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初,岁月如故。", "img1": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131028000000000_1774761252921.jpg", "img2": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131035000000000_1774761281171.jpg", "type": "overlap", "title": "相知", "height": "auto", "subtitle": "FALL IN LOVE", "borderStyle": "mat", "singleWidth": "default", "singleKeepRatio": true}, {"desc": "星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此,还有无数个明天。", "type": "one-large-five-small", "title": "相恋", "height": "80vh", "images": ["https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131108000000000_1774761363179_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131114000000000_1774761355188_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131118000000000_1774761347929_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131120000000000_1774761370955_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131124000000000_1774761366112_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131135000000000_1774761358034_compressed.webp"], "subtitle": "ROMANCE", "borderStyle": "mat", "singleWidth": "default", "singleKeepRatio": true}, {"desc": "往后余生,风雪是你,平淡是你。", "type": "nine-grid", "title": "相守", "height": "80vh", "images": ["https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131539000000000_1774761212027_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131544000000000_1774761239495_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131555000000000_1774761227881_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131551000000000_1774761286259_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131612000000000_1774761242805_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131605000000000_1774761217821_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131627000000000_1774761237037_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131646000000000_1774761259772_compressed.webp", "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/131631000000000_1774762692925_compressed.webp"], "subtitle": "FOREVER", "borderStyle": "mat", "singleWidth": "default", "singleKeepRatio": true}, {"desc": "如果您恰好有空,欢迎来见证幸福!", "img1": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/132308000000000_1774761382424_compressed.webp", "type": "single", "title": "我们的婚礼", "height": "80vh", "subtitle": "Our wedding", "borderStyle": "none", "singleWidth": "full", "singleKeepRatio": true}, {"desc": "始于初见,止于终老,谨定于9月4日,盼与您分享我们的幸福时刻。", "img1": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/132546000000000_1774761352228_compressed.webp", "type": "single", "title": "予君红妆,共赴白头", "height": "100vh", "subtitle": "Adorn you in crimson bridal grace, together we grow old, hand in hand.", "borderStyle": "none", "singleWidth": "full", "singleKeepRatio": true}, {"desc": "从乍见之欢到久处不厌,借此时光温润,定于X月X日,愿与您共饮一杯岁月的酒。", "img1": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/132803000000000_1774761315900_compressed.webp", "type": "single", "title": "岁序温润,共饮此杯", "height": "100vh", "subtitle": "As the gentle year unfolds, let us share this cup.", "borderStyle": "none", "singleWidth": "full", "singleKeepRatio": true}, {"desc": "既许一人以偏爱,愿尽余生之慷慨;届时聊备喜筵,敬邀阖家光临。", "img1": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/133128000000000_1774761336736_compressed.webp", "type": "single", "title": "三餐四季,有人共尝", "height": "100vh", "subtitle": "Three meals a day, four seasons a year—shared with someone dear.", "borderStyle": "none", "singleWidth": "full", "singleKeepRatio": true}], "scrollMode": "free", "formalText1": "源同一脉 庆衍二宗", "formalText2": "缔此良缘 匹配同称", "calendarDate": "2026-09-04", "introEnabled": true, "petalEnabled": true, "bubbleEnabled": true, "activeMusicUrl": "https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/165608000000000_M500001mh1Wp3Qq0BG.mp3", "introExitEffect": "lift", "formalPageHeight": "60vh", "freeScrollInterval": 10, "pageSwitchDuration": 800, "firstScreenDuration": 1300})
// const data = ref({})
const audio = useAudio()
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSubmitted = ref(false)
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0)
const isSnapping = ref(false)
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMapOptions = ref(false)
const showOpenInBrowser = ref(false)
const showBlessingDrawer = ref(false)
const blessingSubmitted = ref(false)
const blessingCloseLeft = ref(0)
let blessingCloseTimer = null
let blessingCloseTick = null
const showPlayerPanel = ref(false)
const showVolumeSlider = ref(false)
const showPanelTrackList = ref(false)
const lrcLines = ref([])
const lrcListRef = ref(null)
const danmakuLayerRef = ref(null)
const seekMax = computed(() => Math.max(audio.duration.value || 0, audio.currentTime.value || 0, 1))
const seekPercent = computed(() => {
const d = audio.duration.value || 0
if (!d) return '0%'
return `${Math.min(100, Math.max(0, (audio.currentTime.value / d) * 100))}%`
})
const volumeIconClass = computed(() => {
const v = audio.volume.value || 0
if (v <= 0.001) return 'fa-solid fa-volume-xmark'
if (v < 0.45) return 'fa-solid fa-volume-low'
return 'fa-solid fa-volume-high'
})
const toggleVolumeSlider = () => {
showVolumeSlider.value = !showVolumeSlider.value
if (showVolumeSlider.value) showPanelTrackList.value = false
}
const playPrevTrack = () => {
audio.prev()
if (data.value.musicRandomEnabled && audio.activeUrl.value) rememberMusicPick(audio.activeUrl.value)
}
const playNextTrack = () => {
audio.next()
if (data.value.musicRandomEnabled && audio.activeUrl.value) rememberMusicPick(audio.activeUrl.value)
}
const activeLrcIndex = computed(() => findLrcIndex(lrcLines.value, audio.currentTime.value))
const loadLrcForActive = async () => {
const url = audio.currentLrcUrl.value
if (!url) {
lrcLines.value = []
return
}
try {
const res = await fetch(url)
const text = await res.text()
lrcLines.value = parseLrc(text)
} catch {
lrcLines.value = []
}
}
const openPlayerPanel = async () => {
showPlayerPanel.value = true
showVolumeSlider.value = false
showPanelTrackList.value = false
await loadLrcForActive()
}
const onSeekInput = (e) => {
audio.seek(Number(e.target.value))
}
const onVolumeInput = (e) => {
audio.setVolume(Number(e.target.value))
}
watch(
() => audio.activeUrl.value,
() => {
if (showPlayerPanel.value) loadLrcForActive()
}
)
watch(activeLrcIndex, async (idx) => {
if (idx < 0 || !lrcListRef.value) return
await nextTick()
const el = lrcListRef.value.querySelectorAll('.player-lrc-line')[idx]
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
const aiStyleOpen = ref('')
const aiBlessingLoading = ref(false)
const aiBlessingTarget = ref('')
const blessingStyles = BLESSING_STYLES
const likeBurstRef = ref(null)
const likeCount = ref(0)
const likePulse = ref(false)
const likeBusy = ref(false)
let likePulseTimer = null
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
const albumPreviewImages = ref([])
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
const danmakuColorOptions = DANMAKU_COLORS
const danmakuGradientOptions = DANMAKU_GRADIENT_COLORS
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
const clearBlessingAutoClose = () => {
clearTimeout(blessingCloseTimer)
clearInterval(blessingCloseTick)
blessingCloseTimer = null
blessingCloseTick = null
blessingCloseLeft.value = 0
}
const openBlessingDrawer = () => {
clearBlessingAutoClose()
blessingSubmitted.value = false
showBlessingDrawer.value = true
}
const closeBlessingDrawer = () => {
clearBlessingAutoClose()
showBlessingDrawer.value = false
blessingSubmitted.value = false
}
const scheduleBlessingAutoClose = () => {
clearBlessingAutoClose()
blessingCloseLeft.value = 5
blessingCloseTick = setInterval(() => {
blessingCloseLeft.value = Math.max(0, blessingCloseLeft.value - 1)
}, 1000)
blessingCloseTimer = setTimeout(() => {
closeBlessingDrawer()
}, 5000)
}
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
/** free 模式自维护的滚动偏移,避免每帧读 scrollY 触发 iOS 同步重排/撕裂 */
let freeScrollOffset = -1
/** 安卓:上一帧时间戳(按 dt 推进) */
let freeScrollLastTs = 0
/** 最近一次真正写入的位置(安卓为整数 scrollTop;iOS transform 可为小数) */
let freeScrollLastApplied = -1
/** 进度条节流 */
let freeScrollProgressTick = 0
let scrollHeightObserver = null
/**
* iOS 自由自动滚:用 GPU transform 平移内容,全程不写 scrollTop。
* 每帧写 scrollTop 会与 Safari 滚动合成器冲突 → 全程抽搐。
*/
let iosTransformActive = false
const BOTTOM_RETURN_DELAY = 30000
/** 每 freeScrollInterval ms 推进的基准像素(与配置滑块联动) */
const FREE_SCROLL_BASE_STEP = 1.35
/** iOS transform 提速 */
const FREE_SCROLL_IOS_BOOST = 1.85
/** 安卓单帧最大计入时长 */
const FREE_SCROLL_MAX_DT = 40
const freeInterval = computed(() => {
const v = Number(data.value.freeScrollInterval)
return v >= 10 && v <= 1000 ? v : 50
})
/** px / ms(安卓按真实 dt) */
const freeScrollVelocity = computed(() => FREE_SCROLL_BASE_STEP / freeInterval.value)
/** iOS:每帧固定推进(支持亚像素,transform 更顺) */
const freeScrollIosStep = computed(() => {
const perFrame = (FREE_SCROLL_BASE_STEP / freeInterval.value) * FREE_SCROLL_IOS_BOOST * (1000 / 60)
return Math.max(0.8, Math.round(perFrame * 100) / 100)
})
const freeScrollSignature = computed(() => `${data.value.scrollMode}:${freeInterval.value}`)
const invalidateFreeScrollAnchor = () => {
freeScrollOffset = -1
freeScrollLastTs = 0
freeScrollLastApplied = -1
freeScrollProgressTick = 0
}
const restartAutoScroll = () => {
if (snapTimer || rafId) startAutoScroll()
}
const scrollingRoot = () => document.scrollingElement || document.documentElement
const isFreeAutoScrolling = () =>
!!rafId && data.value.scrollMode !== 'snap' && isAutoScroll.value && !isInteracting.value
const pageScrollTopNative = () => scrollingRoot().scrollTop || window.scrollY || 0
const exitIosTransformScroll = () => {
if (!iosTransformActive) return
const el = scrollContainerRef.value
const y = Math.max(0, freeScrollOffset >= 0 ? freeScrollOffset : 0)
iosTransformActive = false
document.documentElement.classList.remove('ios-free-transform-scroll', 'free-auto-scrolling')
if (el) {
el.style.transform = ''
el.style.willChange = ''
}
scrollingRoot().scrollTop = y
freeScrollLastApplied = y | 0
invalidateScrollMax()
}
const enterIosTransformScroll = () => {
if (!isIOS || iosTransformActive) return
const el = scrollContainerRef.value
if (!el) return
const y = Math.max(0, freeScrollOffset >= 0 ? freeScrollOffset : pageScrollTopNative())
freeScrollOffset = y
iosTransformActive = true
document.documentElement.classList.add('ios-free-transform-scroll', 'free-auto-scrolling')
el.style.willChange = 'transform'
// 原生滚回顶,视觉位置交给 transform,避免双通道互抢
scrollingRoot().scrollTop = 0
window.scrollTo(0, 0)
el.style.transform = `translate3d(0, ${-y}px, 0)`
freeScrollLastApplied = y
invalidateScrollMax()
refreshScrollMax()
}
const applyFreeScrollTop = (y) => {
const top = Math.max(0, y)
if (isIOS) {
if (!iosTransformActive) enterIosTransformScroll()
const el = scrollContainerRef.value
if (!el) return
// transform 允许亚像素,每帧直接写(GPU,不触发滚动合成)
el.style.transform = `translate3d(0, ${-top}px, 0)`
freeScrollLastApplied = top
return
}
const intTop = top | 0
if (intTop === freeScrollLastApplied) return
freeScrollLastApplied = intTop
scrollingRoot().scrollTop = intTop
}
const switchDuration = computed(() => {
const v = Number(data.value.pageSwitchDuration)
return v >= 0 && v <= 5000 ? v : 800
})
const firstScreenDuration = computed(() => {
const v = Number(data.value.firstScreenDuration)
return v >= 0 && v <= 30000 ? v : 4500
})
const curtainBg = computed(() => ({ backgroundImage: `url(${optimizeImageUrl(data.value.heroImg)})` }))
const endImage = computed(() => optimizeImageUrl(data.value.endImg || data.value.heroImg))
const introExitClass = computed(() => {
const effect = ['wind', 'book', 'lift', 'doors'].includes(data.value.introExitEffect) ? data.value.introExitEffect : 'wind'
return `intro-exit-${effect}`
})
const introExitDuration = computed(() => ({ wind: 1280, book: 1680, lift: 980, doors: 1180 }[data.value.introExitEffect] || 1280))
const calendarWeekdays = ['一', '二', '三', '四', '五', '六', '日']
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const parsedCalendarDate = computed(() => {
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(data.value.calendarDate || '')
const fallback = { year: 2026, month: 9, day: 4 }
if (!m) return fallback
const year = Number(m[1])
const month = Math.min(12, Math.max(1, Number(m[2])))
const last = new Date(year, month, 0).getDate()
const day = Math.min(last, Math.max(1, Number(m[3])))
return { year, month, day }
})
const weddingDay = computed(() => parsedCalendarDate.value.day)
const calendarTitle = computed(() => ({
year: parsedCalendarDate.value.year,
monthText: monthNames[parsedCalendarDate.value.month - 1],
}))
const weddingDateText = computed(() => {
const d = parsedCalendarDate.value
return `${d.year}年${d.month}月${d.day}日`
})
const calendarDays = computed(() => {
const { year, month } = parsedCalendarDate.value
const firstDay = new Date(year, month - 1, 1).getDay()
const leading = (firstDay + 6) % 7
const days = new Date(year, month, 0).getDate()
const cells = [...Array.from({ length: leading }, () => null), ...Array.from({ length: days }, (_, i) => i + 1)]
const trailing = (7 - (cells.length % 7)) % 7
return [...cells, ...Array.from({ length: trailing }, () => null)]
})
const sectionHeightStyle = (height) => {
let h = height || '100dvh'
if (h === 'auto') return { minHeight: 'auto' }
// 后端配置可能是 '100vh',统一升级为 dvh(iOS 上 vh 会因地址栏伸缩抖动)
if (/^\d+(\.\d+)?vh$/.test(h)) h = h.replace('vh', 'dvh')
return { minHeight: h }
}
const formalPageHeightStyle = computed(() => sectionHeightStyle(data.value.formalPageHeight))
const pageHeightStyle = (page) => sectionHeightStyle(page.height)
const normalizeImages = (images = []) => {
const seen = new Set()
return images
.filter((src) => typeof src === 'string' && src.trim())
.map((src) => optimizeImageUrl(src.trim()))
.filter((src) => {
if (seen.has(src)) return false
seen.add(src)
return true
})
}
const localAlbumImages = () => {
// 灯箱优先原图;无原图时再退缩放图
const images = [data.value.heroImg, data.value.endImg]
;(data.value.photoPages || []).forEach((page) => {
images.push(page.img1 || page.img1Resized, page.img2 || page.img2Resized)
if (Array.isArray(page.images)) {
page.images.forEach((src, i) => {
images.push(src || page.imagesResized?.[i])
})
}
})
return normalizeImages(images)
}
const loadAlbumPreviewImages = async () => {
if (albumLoaded.value && albumPreviewImages.value.length) return
const fallback = localAlbumImages()
try {
const res = await getAlbumImages()
albumPreviewImages.value = normalizeImages([...(res.data || []), ...fallback])
} catch (e) {
albumPreviewImages.value = fallback
}
albumLoaded.value = true
}
const handlePreviewVisibleChange = (visible) => {
previewVisible.value = visible
if (visible) {
clearBottomReturn()
cancelAnimationFrame(animId)
return
}
if (isAutoScroll.value && isAtBottom()) scheduleBottomReturn(true)
}
const albumPreviewOptions = computed(() => ({
visible: previewVisible.value,
current: previewIndex.value,
onVisibleChange: handlePreviewVisibleChange,
}))
const openImagePreview = async (src) => {
// 兼容 computed / 非字符串误传
const url = typeof src === 'string' ? src.trim() : String(src?.value || src || '').trim()
if (!url || url === '[object Object]') return
await loadAlbumPreviewImages()
let images = albumPreviewImages.value.length ? [...albumPreviewImages.value] : []
let index = images.indexOf(url)
if (index < 0) {
images = [url, ...images]
albumPreviewImages.value = images
index = 0
}
previewIndex.value = index
// 等 a-image 挂载并 registerImage 后再打开,避免灯箱空白
await nextTick()
await nextTick()
previewVisible.value = true
clearBottomReturn()
cancelAnimationFrame(animId)
}
const effectsActive = computed(() =>
!isDrawerOpen.value && !showBlessingDrawer.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
)
const closeDrawer = () => { isDrawerOpen.value = false }
const pageScrollTop = () => {
if (iosTransformActive && freeScrollOffset >= 0) return freeScrollOffset
return pageScrollTopNative()
}
/** 缓存文档高度,避免每次 scroll 读 scrollHeight 触发强制重排 */
let cachedScrollMax = 0
let scrollRaf = 0
const viewportHeight = () => {
const vv = window.visualViewport
return (vv && vv.height) || window.innerHeight || document.documentElement.clientHeight || 0
}
const refreshScrollMax = () => {
if (iosTransformActive && scrollContainerRef.value) {
cachedScrollMax = Math.max(0, scrollContainerRef.value.scrollHeight - viewportHeight())
} else {
const root = scrollingRoot()
cachedScrollMax = Math.max(0, root.scrollHeight - viewportHeight())
}
return cachedScrollMax
}
const pageScrollMax = () => (cachedScrollMax > 0 ? cachedScrollMax : refreshScrollMax())
const invalidateScrollMax = () => { cachedScrollMax = 0 }
const handleScroll = () => {
// transform 自动滚 / 自由自动滚期间不读布局
if (iosTransformActive || isFreeAutoScrolling()) return
if (scrollRaf) return
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0
const max = pageScrollMax()
const top = pageScrollTop()
progress.value = max > 0 ? top / max : 0
if (max - top <= 8) scheduleBottomReturn()
else clearBottomReturn()
})
}
const handleUserInteraction = () => {
// 用户插手:立刻退出 transform,把位置交还给原生滚动
if (iosTransformActive) exitIosTransformScroll()
isInteracting.value = true
cancelAnimationFrame(animId)
clearTimeout(pauseTimer)
invalidateFreeScrollAnchor()
if (isAtBottom()) scheduleBottomReturn(true)
pauseTimer = setTimeout(() => {
isInteracting.value = false
invalidateFreeScrollAnchor()
}, 3000)
}
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
const isAtBottom = () => {
const max = pageScrollMax()
return max - pageScrollTop() <= 8
}
const clearBottomReturn = () => {
clearTimeout(bottomReturnTimer)
bottomReturnTimer = null
}
const scheduleBottomReturn = (reset = false) => {
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value || showBlessingDrawer.value) return
if (bottomReturnTimer && !reset) return
clearBottomReturn()
bottomReturnTimer = setTimeout(() => {
bottomReturnTimer = null
if (!isAtBottom() || !isAutoScroll.value || isDrawerOpen.value || previewVisible.value) return
if (isInteracting.value) {
scheduleBottomReturn(true)
return
}
smoothScrollTo(0, switchDuration.value)
}, BOTTOM_RETURN_DELAY)
}
const smoothScrollTo = (target, duration) => {
cancelAnimationFrame(animId)
if (iosTransformActive) exitIosTransformScroll()
const start = pageScrollTopNative(), dist = target - start
if (!dist) return
invalidateFreeScrollAnchor()
if (duration <= 0) { window.scrollTo(0, 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
window.scrollTo(0, start + dist * e)
if (p < 1) animId = requestAnimationFrame(tick)
}
animId = requestAnimationFrame(tick)
}
const goNextSection = () => {
if (isSnapping.value) return
const top = pageScrollTop()
const next = sectionEls().find((e) => e.offsetTop > top + 12)
if (!next) {
scheduleBottomReturn()
return
}
isSnapping.value = true
next.scrollIntoView({ behavior: 'smooth', block: 'start' })
setTimeout(() => { isSnapping.value = false }, switchDuration.value + 120)
}
const canAutoAdvance = () =>
isAutoScroll.value
&& !isInteracting.value
&& !isSnapping.value
&& !isDrawerOpen.value
&& !showBlessingDrawer.value
&& !previewVisible.value
&& !showMapOptions.value
&& !showOpenInBrowser.value
/** 图挂载后刷新文档高度(画廊不停滚,仅校正 scrollMax) */
const onGalleryReady = () => {
invalidateScrollMax()
refreshScrollMax()
}
const startAutoScroll = () => {
clearTimeout(scrollStartTimer)
clearInterval(snapTimer)
clearInterval(freeTimer)
cancelAnimationFrame(rafId)
rafId = null
if (data.value.scrollMode === 'snap') {
exitIosTransformScroll()
document.documentElement.classList.remove('free-auto-scrolling')
if (canAutoAdvance()) goNextSection()
snapTimer = setInterval(() => {
if (canAutoAdvance()) goNextSection()
}, 4500)
} else {
invalidateFreeScrollAnchor()
const tickFreeScroll = (now) => {
if (canAutoAdvance()) {
// 重新锚定:从真实位置续滚(打断/暂停后)
if (freeScrollOffset < 0) {
freeScrollOffset = iosTransformActive ? Math.max(0, freeScrollLastApplied) : pageScrollTopNative()
freeScrollLastTs = now
freeScrollLastApplied = -1
}
if (isIOS) {
if (!iosTransformActive) enterIosTransformScroll()
freeScrollOffset += freeScrollIosStep.value
} else {
const dt = Math.min(FREE_SCROLL_MAX_DT, Math.max(0, now - (freeScrollLastTs || now)))
freeScrollLastTs = now
freeScrollOffset += freeScrollVelocity.value * dt
}
const max = pageScrollMax()
if (max - freeScrollOffset <= 8) {
freeScrollOffset = max
applyFreeScrollTop(freeScrollOffset)
progress.value = 1
scheduleBottomReturn()
} else {
applyFreeScrollTop(freeScrollOffset)
freeScrollProgressTick += 1
if (freeScrollProgressTick % 10 === 0 && max > 0) {
progress.value = Math.min(1, freeScrollOffset / max)
}
}
} else {
if (iosTransformActive) exitIosTransformScroll()
else document.documentElement.classList.remove('free-auto-scrolling')
if (freeScrollOffset >= 0) invalidateFreeScrollAnchor()
}
rafId = requestAnimationFrame(tickFreeScroll)
}
rafId = requestAnimationFrame(tickFreeScroll)
}
}
const syncPageScrollMode = () => {
document.documentElement.classList.toggle('page-scroll-snap', data.value.scrollMode === 'snap')
}
const scheduleAutoScrollStart = () => {
clearTimeout(scrollStartTimer)
scrollStartTimer = setTimeout(() => startAutoScroll(), firstScreenDuration.value)
}
const scrollPageToTop = () => {
exitIosTransformScroll()
window.scrollTo(0, 0)
document.documentElement.scrollTop = 0
document.body.scrollTop = 0
freeScrollOffset = 0
}
const toggleAutoScroll = () => {
isAutoScroll.value = !isAutoScroll.value
if (isAutoScroll.value) {
invalidateFreeScrollAnchor()
if (isAtBottom()) scheduleBottomReturn(true)
} else {
clearBottomReturn()
exitIosTransformScroll()
invalidateFreeScrollAnchor()
}
}
const completeIntro = () => {
if (!showIntro.value) return
clearTimeout(introTimer)
// 揭幕结束先回顶,避免落点仍在下方控件上造成误触
scrollPageToTop()
showIntro.value = false
introOpening.value = false
if (pendingLikeBurst > 0) {
const n = pendingLikeBurst
pendingLikeBurst = 0
nextTick(() => likeBurstRef.value?.burst(n))
}
scheduleAutoScrollStart()
}
const openInvitation = () => {
if (introOpening.value) return
clearTimeout(introTimer)
scrollPageToTop()
introOpening.value = true
introTimer = setTimeout(() => completeIntro(), introExitDuration.value)
if (data.value.musicList.length) audio.play()
}
const selectTrack = (url) => {
audio.setActive(url)
showPanelTrackList.value = false
showTrackList.value = false
if (data.value.musicRandomEnabled) rememberMusicPick(url)
}
const mapKeyword = computed(() => `${data.value.hotel || ''} ${data.value.address || ''}`.replace(/\s+/g, ' ').trim())
const isMobileDevice = () => /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
const isIOSDevice = () => /iPhone|iPad|iPod/i.test(navigator.userAgent)
const isWeChat = () => /MicroMessenger/i.test(navigator.userAgent)
const mapWebUrl = (provider) => {
const q = encodeURIComponent(mapKeyword.value || data.value.hotel || data.value.address || '')
if (provider === 'baidu') return `https://api.map.baidu.com/geocoder?address=${q}&output=html&src=hunliji`
if (provider === 'tencent') return `https://apis.map.qq.com/uri/v1/search?keyword=${q}&referer=hunliji`
return `https://uri.amap.com/search?keyword=${q}&callnative=${isMobileDevice() ? 1 : 0}`
}
const mapAppUrl = (provider) => {
const q = encodeURIComponent(mapKeyword.value || data.value.hotel || data.value.address || '')
const name = encodeURIComponent(data.value.hotel || mapKeyword.value || '婚礼酒店')
if (provider === 'baidu') return `baidumap://map/direction?destination=name:${name}|addr:${q}&mode=driving&src=hunliji`
if (provider === 'tencent') return `qqmap://map/routeplan?type=drive&to=${name}&referer=hunliji`
if (isIOSDevice()) return `iosamap://path?sourceApplication=hunliji&dname=${name}&dev=0&t=0`
return `androidamap://route/plan/?sourceApplication=hunliji&dname=${name}&dev=0&t=0`
}
const openMap = (provider = 'amap') => {
showMapOptions.value = false
if (!isMobileDevice()) {
window.open(mapWebUrl(provider), '_blank', 'noopener')
return
}
const startedAt = Date.now()
window.location.href = mapAppUrl(provider)
setTimeout(() => {
if (document.hidden || Date.now() - startedAt > 1800) return
window.location.href = mapWebUrl(provider)
}, 900)
}
const openNavigation = () => {
if (isWeChat()) {
showMapOptions.value = false
showOpenInBrowser.value = true
return
}
if (isMobileDevice()) showMapOptions.value = true
else openMap('amap')
}
const toggleAiStyle = (target) => {
if (aiBlessingLoading.value) return
aiStyleOpen.value = aiStyleOpen.value === target ? '' : target
}
const pickAiStyle = async (target, style) => {
aiStyleOpen.value = ''
aiBlessingTarget.value = target
aiBlessingLoading.value = true
try {
const res = await generateAiBlessing({ style })
const text = res.data?.text || ''
if (!text) throw new Error('未生成内容')
if (target === 'rsvp') rsvpForm.wishes = text
else blessingForm.content = text
} catch (e) {
toast.error(e.message || 'AI 生成失败')
} finally {
aiBlessingLoading.value = false
aiBlessingTarget.value = ''
}
}
const handleSubmitRsvp = async () => {
if (!rsvpForm.name.trim()) return toast.error('请填写姓名')
try {
const res = await submitRsvp(rsvpForm)
if (res.code === 200) {
if (res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
isSubmitted.value = true
}
else toast.error(res.error || '提交失败')
} catch (e) { toast.error(e.message || '提交失败,请检查后端运行状态') }
}
const handleSubmitBlessing = async () => {
if (!blessingForm.name.trim()) return toast.error('请填写姓名')
if (!blessingForm.content.trim()) return toast.error('请填写祝福')
try {
const res = await submitDanmaku({
name: blessingForm.name.trim(),
content: blessingForm.content.trim(),
color: blessingForm.color || DEFAULT_DANMAKU_COLOR,
})
if (res.code === 200) {
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
blessingSubmitted.value = true
scheduleBlessingAutoClose()
}
else toast.error(res.error || '提交失败')
} catch (e) {
toast.error(e.message || '提交失败,请检查后端运行状态')
}
}
let pendingLikeBurst = 0
const loadLikeStatus = async () => {
try {
const res = await getLikeStatus(getClientId())
const count = Number(res.data?.count) || 0
likeCount.value = count
if (count <= 0) return
// 揭幕中则等结束后再播,避免帘幕后白播
if (showIntro.value) pendingLikeBurst = count
else nextTick(() => likeBurstRef.value?.burst(count))
} catch { /* ignore */ }
}
const handleLike = async () => {
if (likeBusy.value) return
likeBusy.value = true
try {
const res = await submitLike(getClientId())
likeCount.value = Number(res.data?.count) || likeCount.value + 1
likePulse.value = true
clearTimeout(likePulseTimer)
likePulseTimer = setTimeout(() => { likePulse.value = false }, 600)
likeBurstRef.value?.burst(1)
} catch (e) {
toast.error(e.message || '点赞失败')
} finally {
likeBusy.value = false
}
}
onMounted(async () => {
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
window.scrollTo(0, 0)
try {
const res = await getConfig()
if (res.data && res.data !== '{}') {
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
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
loaded.firstScreenDuration = Number.isFinite(Number(loaded.firstScreenDuration))
? Number(loaded.firstScreenDuration)
: DEFAULTS.firstScreenDuration
loaded.formalPageHeight = loaded.formalPageHeight || DEFAULTS.formalPageHeight
loaded.calendarDate = loaded.calendarDate || DEFAULTS.calendarDate
loaded.introExitEffect = loaded.introExitEffect || DEFAULTS.introExitEffect
// 安卓专项:OSS 图统一压缩为 750px webp(iOS 不受影响)
if (loaded.heroImg) loaded.heroImg = optimizeImageUrl(loaded.heroImg)
if (loaded.endImg) loaded.endImg = optimizeImageUrl(loaded.endImg)
if (Array.isArray(loaded.photoPages)) {
loaded.photoPages = loaded.photoPages.map((p) => {
if (!p) return p
const next = { ...p }
if (next.img1) next.img1 = optimizeImageUrl(next.img1)
if (next.img2) next.img2 = optimizeImageUrl(next.img2)
if (next.img1Resized) next.img1Resized = optimizeImageUrl(next.img1Resized)
if (next.img2Resized) next.img2Resized = optimizeImageUrl(next.img2Resized)
if (Array.isArray(next.images)) next.images = next.images.map((u) => optimizeImageUrl(u))
if (Array.isArray(next.imagesResized)) {
next.imagesResized = next.imagesResized.map((u) => (u ? optimizeImageUrl(u) : u))
}
return next
})
}
data.value = { ...data.value, ...DEFAULTS, ...loaded }
}
} catch (e) { /* 使用默认配置 */ }
syncPageScrollMode()
albumLoaded.value = false
// 安卓专项:不在首屏预挂载整本相册,避免 <a-image> 全量创建导致主线程卡顿
// 改为用户首次点开预览时再加载(loadAlbumPreviewImages 内部有缓存保护)
albumPreviewImages.value = []
loadLikeStatus()
sendVisitBeacon()
if (data.value.introEnabled) showIntro.value = true
const pickedUrl = pickMusicUrl(
data.value.musicList,
data.value.activeMusicUrl,
data.value.musicRandomEnabled
)
audio.setTracks(data.value.musicList, pickedUrl)
let autoMusicOk = true
if (data.value.musicList.length) {
autoMusicOk = await audio.attemptAutoplay()
}
if (data.value.introEnabled && autoMusicOk) {
introTimer = setTimeout(() => openInvitation(), 3600)
} else if (!data.value.introEnabled) {
scheduleAutoScrollStart()
}
await nextTick()
refreshScrollMax()
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', invalidateScrollMax, { passive: true })
window.visualViewport?.addEventListener('resize', invalidateScrollMax, { passive: true })
// 内容高度变化时再量 scrollMax,避免自动滚热路径里读 scrollHeight
if (typeof ResizeObserver !== 'undefined' && scrollContainerRef.value) {
scrollHeightObserver = new ResizeObserver(() => { invalidateScrollMax() })
scrollHeightObserver.observe(scrollContainerRef.value)
}
handleScroll()
})
onUnmounted(() => {
exitIosTransformScroll()
document.documentElement.classList.remove('page-scroll-snap', 'free-auto-scrolling', 'ios-free-transform-scroll')
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', invalidateScrollMax)
window.visualViewport?.removeEventListener('resize', invalidateScrollMax)
scrollHeightObserver?.disconnect()
scrollHeightObserver = null
cancelAnimationFrame(scrollRaf)
scrollRaf = 0
cancelAnimationFrame(rafId)
cancelAnimationFrame(animId)
clearInterval(snapTimer)
clearInterval(freeTimer)
clearTimeout(pauseTimer)
clearTimeout(introTimer)
clearTimeout(scrollStartTimer)
clearBottomReturn()
clearBlessingAutoClose()
})
watch(() => data.value.scrollMode, syncPageScrollMode)
watch(
() => data.value.photoPages,
async () => {
await nextTick()
refreshScrollMax()
},
{ deep: true }
)
watch(freeScrollSignature, restartAutoScroll)
watch(
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
() => {
if (isDrawerOpen.value || showBlessingDrawer.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) {
cancelAnimationFrame(animId)
clearBottomReturn()
}
}
)
</script>
<style scoped>
.wrapper {
font-family: var(--font-sans);
font-variant-numeric: lining-nums;
text-rendering: geometricPrecision;
}
.album-preview-registry {
display: none;
}
:global(html.free-auto-scrolling) {
overflow-anchor: none;
}
:global(html.ios-free-transform-scroll),
:global(html.ios-free-transform-scroll body) {
overflow: hidden !important;
height: 100% !important;
overscroll-behavior: none;
touch-action: pan-x pinch-zoom;
}
:global(html.ios-free-transform-scroll .scroll-transform-host) {
will-change: transform;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
:global(html.page-scroll-snap) {
scroll-snap-type: y proximity;
scroll-behavior: auto;
}
.wrapper .font-serif { font-family: var(--font-serif); }
.wrapper .font-sans { font-family: var(--font-sans); }
.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);
}
.action-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 0 16px calc(12px + env(safe-area-inset-bottom, 0px));
pointer-events: none;
}
.action-dock-row {
position: relative;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
pointer-events: none;
z-index: 2;
}
.action-dock-row > * { pointer-events: auto; }
/* 右下点赞/填写周围扩大命中与遮罩,防止点到下方图片 */
.like-side-shield {
position: absolute;
right: 0;
bottom: 0;
width: min(56vw, 240px);
height: calc(200px + env(safe-area-inset-bottom, 0px));
pointer-events: auto;
z-index: 1;
background: transparent;
}
.like-hit-zone {
position: relative;
pointer-events: auto;
padding: 22px 26px 28px;
margin: -22px -26px -28px;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.action-pill {
min-width: 52px;
min-height: 52px;
padding: 8px 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 9999px;
cursor: pointer;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 0.5px solid rgba(168, 140, 107, 0.28);
background: rgba(255, 253, 249, 0.42);
color: #a88c6b;
box-shadow: 0 6px 18px rgba(92, 74, 53, 0.08);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
}
.action-pill:active { transform: scale(0.97); }
.action-pill-icon { width: 18px; height: 18px; display: block; }
.action-rsvp {
font-size: 13px;
letter-spacing: 0.18em;
padding: 10px 18px;
}
.like-fab {
flex-direction: column;
gap: 3px;
padding: 8px 12px;
min-width: 52px;
}
.like-fab.liked {
border-color: rgba(244, 114, 182, 0.45);
background: rgba(255, 241, 247, 0.5);
}
.like-fab.busy { opacity: 0.7; pointer-events: none; }
.like-icon { font-size: 18px; color: #a88c6b; line-height: 1; }
.like-fab.liked .like-icon { color: #f472b6; }
.like-count {
font-size: 11px; letter-spacing: 0.02em; color: #a88c6b;
line-height: 1; text-align: center; max-width: 100%;
}
.like-fab.liked .like-count { color: #f472b6; font-weight: 600; }
.action-comment-row {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.action-comment-row > * { pointer-events: auto; }
.action-comment {
position: relative;
z-index: 2;
pointer-events: auto;
flex: 1;
min-width: 0;
min-height: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 9999px;
cursor: pointer;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 0.5px solid rgba(168, 140, 107, 0.26);
background: rgba(255, 253, 249, 0.38);
color: #a88c6b;
font-size: 13px;
letter-spacing: 0.28em;
box-shadow: 0 8px 22px rgba(92, 74, 53, 0.1);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
}
.action-comment:active { transform: scale(0.99); }
.action-comment-icon { width: 18px; height: 18px; opacity: 0.9; }
.vinyl-wrap {
position: relative;
flex: 0 0 auto;
width: 54px;
height: 54px;
}
.vinyl-disc {
width: 54px;
height: 54px;
border-radius: 999px;
position: relative;
overflow: hidden;
border: none;
background:
radial-gradient(circle at 50% 50%, #1a1410 0 6%, transparent 6.2%),
radial-gradient(circle at 32% 28%, rgba(255, 240, 220, 0.14) 0 10%, transparent 28%),
repeating-radial-gradient(
circle at center,
#1e1813 0 0.6px,
#2c241c 0.6px 1.4px,
#241c16 1.4px 2.2px,
#332a21 2.2px 3px
);
box-shadow:
0 0 0 1px rgba(90, 70, 50, 0.45),
0 0 0 2px rgba(30, 22, 16, 0.55),
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 7px 18px rgba(40, 28, 18, 0.32);
}
.vinyl-disc.spinning { animation: vinyl-spin 4.8s linear infinite; }
.vinyl-rim {
position: absolute;
inset: 0;
border-radius: 999px;
box-shadow:
inset 0 0 0 1.5px rgba(180, 150, 110, 0.22),
inset 0 0 10px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.vinyl-grooves {
position: absolute;
inset: 8%;
border-radius: 999px;
opacity: 0.55;
pointer-events: none;
background:
repeating-radial-gradient(
circle at center,
transparent 0 1.5px,
rgba(255, 245, 230, 0.045) 1.5px 1.9px,
transparent 1.9px 3.4px,
rgba(0, 0, 0, 0.18) 3.4px 3.8px
);
mask-image: radial-gradient(circle at center, transparent 0 26%, #000 28% 92%, transparent 94%);
-webkit-mask-image: radial-gradient(circle at center, transparent 0 26%, #000 28% 92%, transparent 94%);
}
.vinyl-sheen {
position: absolute;
inset: 0;
border-radius: 999px;
pointer-events: none;
background: linear-gradient(
125deg,
rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0) 28%,
rgba(255, 255, 255, 0) 58%,
rgba(255, 255, 255, 0.08) 100%
);
mix-blend-mode: soft-light;
}
.vinyl-label-ring {
position: absolute;
inset: 25%;
border-radius: 999px;
pointer-events: none;
box-shadow:
0 0 0 1px rgba(212, 180, 140, 0.35),
inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.vinyl-label {
position: absolute;
inset: 28%;
border-radius: 999px;
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.2), transparent 42%),
#c4a484 center/cover no-repeat;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.28),
0 1px 3px rgba(0, 0, 0, 0.25);
}
.vinyl-spindle {
position: absolute;
left: 50%;
top: 50%;
width: 5px;
height: 5px;
margin: -2.5px 0 0 -2.5px;
border-radius: 999px;
background: radial-gradient(circle at 35% 30%, #f0e0c8, #8a704f 70%, #5c4a35);
box-shadow: 0 0 0 1px rgba(40, 28, 18, 0.35);
pointer-events: none;
z-index: 2;
}
.vinyl-play-fab {
position: absolute;
right: -5px;
bottom: -5px;
width: 26px;
height: 26px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #fff;
background: linear-gradient(145deg, #c4a484, #a88c6b);
box-shadow: 0 3px 10px rgba(92, 74, 53, 0.28);
z-index: 2;
}
.vinyl-play-fab .fa-play { margin-left: 1px; }
.scroll-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 10px;
z-index: 55;
pointer-events: none;
}
.scroll-progress-track {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
rgba(168, 140, 107, 0.08),
rgba(168, 140, 107, 0.18),
rgba(168, 140, 107, 0.08)
);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.35);
}
.scroll-progress-fill {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, #d4b48a 0%, #a88c6b 55%, #8a704f 100%);
box-shadow: 0 0 8px rgba(168, 140, 107, 0.45);
transform: scaleX(var(--scroll-p, 0));
transform-origin: left center;
will-change: transform;
}
.scroll-progress-xi {
position: absolute;
top: 0;
left: 0;
font-size: 11px;
line-height: 1;
color: #a88c6b;
text-shadow:
0 0 6px rgba(253, 251, 247, 0.95),
0 1px 0 rgba(255, 255, 255, 0.8);
transform: translate3d(calc(var(--scroll-p, 0) * (100vw - 1.1em) + 0.55em - 50%), 0, 0);
will-change: transform;
font-weight: 700;
letter-spacing: 0;
}
.player-panel {
position: absolute;
left: 0;
bottom: calc(100% + 14px);
width: min(82vw, 308px);
padding: 14px 14px 12px;
border-radius: 26px;
background: rgba(255, 252, 247, 0.39);
border: none;
box-shadow:
0 16px 40px rgba(92, 74, 53, 0.16),
0 2px 0 rgba(255, 255, 255, 0.55) inset;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
z-index: 20;
}
.player-panel-arrow {
position: absolute;
left: 18px;
bottom: -7px;
width: 14px;
height: 14px;
background: rgba(255, 252, 247, 0.9);
transform: rotate(45deg);
border-radius: 3px;
box-shadow: 2px 2px 6px rgba(92, 74, 53, 0.08);
}
.player-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
padding: 0 2px;
}
.player-panel-meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.player-panel-cover {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 12px;
background: #c4a484 center/cover no-repeat;
box-shadow: 0 3px 10px rgba(92, 74, 53, 0.18);
}
.player-panel-title {
font-size: 14px;
color: #5c4a35;
letter-spacing: 0.06em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.player-panel-close {
width: 28px;
height: 28px;
border-radius: 999px;
color: #9a9084;
font-size: 12px;
background: rgba(168, 140, 107, 0.1);
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.player-lrc-list {
max-height: 250px;
overflow-y: auto;
padding: 10px 8px;
border-radius: 18px;
background: rgba(168, 140, 107, 0.07);
border: none;
}
.player-lrc-line {
margin: 0;
padding: 7px 8px;
font-family: var(--font-calligraphy);
font-size: 12px;
line-height: 1.55;
color: rgb(154, 144, 132);
text-align: center;
transition: color 0.2s, transform 0.2s, background 0.2s;
border-radius: 999px;
}
.player-lrc-line.active {
color: #5c4a35;
font-weight: 600;
background: rgba(255, 255, 255, 0.36);
transform: scale(1.02);
}
.player-lrc-empty {
margin: 0;
padding: 18px 8px;
text-align: center;
font-size: 12px;
color: #9a9084;
}
.player-panel-footer {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.player-transport {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.player-skip-btn {
width: 30px;
height: 30px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #a88c6b;
font-size: 12px;
background: rgba(168, 140, 107, 0.1);
}
.player-skip-btn:disabled {
opacity: 0.35;
pointer-events: none;
}
.player-play-btn {
width: 42px;
height: 42px;
border-radius: 999px;
background: linear-gradient(145deg, #c4a484, #a88c6b);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
box-shadow: 0 6px 14px rgba(168, 140, 107, 0.35);
font-size: 14px;
}
.player-play-btn .fa-play { margin-left: 2px; }
.player-panel-sliders {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.player-vol-row {
display: flex;
align-items: center;
gap: 4px;
color: #a88c6b;
min-height: 28px;
}
.player-list-wrap {
position: relative;
flex: 0 0 auto;
}
.player-vol-icon {
width: 28px;
height: 28px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #a88c6b;
font-size: 12px;
flex: 0 0 auto;
background: rgba(168, 140, 107, 0.1);
}
.player-track-pop {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
width: min(70vw, 220px);
max-height: 180px;
padding: 10px 8px;
border-radius: 16px;
background: rgba(255, 252, 247, 0.96);
box-shadow: 0 12px 28px rgba(92, 74, 53, 0.16);
z-index: 30;
display: flex;
flex-direction: column;
}
.player-track-pop-title {
font-size: 11px;
color: #9a9084;
letter-spacing: 0.12em;
padding: 0 6px 6px;
}
.player-track-pop-list {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.player-track-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
padding: 8px 10px;
border-radius: 12px;
font-size: 12px;
color: #5c4a35;
}
.player-track-item.active {
background: rgba(168, 140, 107, 0.14);
color: #a88c6b;
font-weight: 600;
}
.player-track-item .tname {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-track-item .tcheck { font-size: 11px; }
.player-vol-track {
flex: 1;
min-width: 0;
height: 28px;
display: flex;
align-items: center;
}
.player-vol-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 999px;
outline: none;
background: linear-gradient(
90deg,
#c4a484 0%,
#a88c6b var(--vol, 50%),
rgba(168, 140, 107, 0.18) var(--vol, 50%),
rgba(168, 140, 107, 0.18) 100%
);
cursor: pointer;
}
.player-vol-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 999px;
background: #fff;
border: none;
box-shadow: 0 1px 4px rgba(92, 74, 53, 0.28), 0 0 0 1px rgba(168, 140, 107, 0.35);
cursor: pointer;
transition: transform 0.15s ease;
}
.player-vol-range:active::-webkit-slider-thumb { transform: scale(1.12); }
.player-vol-range::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 999px;
background: #fff;
border: none;
box-shadow: 0 1px 4px rgba(92, 74, 53, 0.28), 0 0 0 1px rgba(168, 140, 107, 0.35);
cursor: pointer;
}
.player-seek-wrap { width: 100%; }
.player-time {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #9a9084;
margin-top: 3px;
padding: 0 2px;
}
.player-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 999px;
outline: none;
background: linear-gradient(
90deg,
#c4a484 0%,
#a88c6b var(--seek, 0%),
rgba(168, 140, 107, 0.18) var(--seek, 0%),
rgba(168, 140, 107, 0.18) 100%
);
cursor: pointer;
}
.player-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 999px;
background: #fff;
border: none;
box-shadow: 0 1px 4px rgba(92, 74, 53, 0.28), 0 0 0 1px rgba(168, 140, 107, 0.35);
cursor: pointer;
}
.player-range::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 999px;
background: #fff;
border: none;
box-shadow: 0 1px 4px rgba(92, 74, 53, 0.28), 0 0 0 1px rgba(168, 140, 107, 0.35);
cursor: pointer;
}
@keyframes vinyl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.danmaku-color-row {
display: flex; flex-wrap: wrap; gap: 10px; padding: 2px 2px 0;
}
.danmaku-color-dot {
width: 26px; height: 26px; border-radius: 9999px; border: 2px solid transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06); cursor: pointer; padding: 0;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.danmaku-color-dot.active {
border-color: #2c2c2c;
box-shadow: 0 0 0 2px rgba(168,140,107,0.35);
transform: scale(1.08);
}
.danmaku-color-dot--grad {
width: 30px;
height: 30px;
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.55),
0 2px 8px rgba(88,68,42,0.18),
0 0 12px rgba(255,140,160,0.22);
}
.ai-bless-wrap { position: relative; }
.ai-bless-btn {
font-size: 10px; letter-spacing: 0.12em; color: #a88c6b;
background: transparent; border: 0.5px solid rgba(168,140,107,0.45);
border-radius: 999px; padding: 3px 10px; line-height: 1.4;
transition: background .2s, color .2s;
}
.ai-bless-btn:hover:not(:disabled) { background: rgba(168,140,107,0.1); }
.ai-bless-btn:disabled { opacity: .55; cursor: wait; }
.ai-style-card {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 5;
width: 148px; padding: 10px 8px; background: #fffdf9;
border: 0.5px solid rgba(168,140,107,0.35); border-radius: 14px;
box-shadow: 0 10px 28px rgba(92,74,53,0.12);
display: flex; flex-direction: column; gap: 4px;
}
.ai-style-title {
font-size: 10px; letter-spacing: 0.2em; color: #8a8680; padding: 2px 6px 6px;
}
.ai-style-item {
text-align: left; font-size: 12px; color: #5c4a35;
padding: 8px 10px; border-radius: 10px; transition: background .15s;
}
.ai-style-item:hover { background: rgba(168,140,107,0.12); }
.vertical-text { writing-mode: vertical-rl; letter-spacing: 0.4em; text-orientation: upright; font-family: var(--font-kai); }
.formal-copy {
align-items: center;
}
.formal-main {
font-family: var(--font-calligraphy);
font-size: 24px;
line-height: 1.9;
font-weight: 800;
letter-spacing: 0.32em;
color: #30271e;
text-shadow: 0 1px 0 rgba(255,255,255,0.45), 0 7px 18px rgba(88,68,42,0.08);
}
.formal-main-offset {
padding-top: 3.25rem;
}
.formal-side {
font-family: var(--font-calligraphy);
color: #806f5c;
font-size: 15px;
line-height: 1.8;
font-weight: 700;
}
.page-section {
min-height: 100vh;
min-height: 100dvh;
width: 100%;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
/* 花瓣形状由 SVG 决定,无需额外形状样式 */
.cover-kicker,
.schedule-kicker {
font-family: var(--font-calligraphy);
}
.cover-names {
font-family: var(--font-calligraphy);
font-weight: 700;
}
.cover-date {
font-family: var(--font-number);
}
.schedule-title {
font-family: var(--font-calligraphy);
font-weight: 700;
}
/* ---------- 婚礼流程 ---------- */
.schedule-page {
background:
linear-gradient(180deg, rgba(253,251,247,0.74), rgba(247,246,242,0.92)),
radial-gradient(circle at 50% 8%, rgba(232,201,210,0.14), transparent 34%);
}
.schedule-page::before,
.schedule-page::after {
content: ''; position: absolute; left: 50%; transform: translateX(-50%);
width: min(62vw, 230px); height: 1px; pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(168,140,107,0.34), transparent);
}
.schedule-page::before { top: 42px; }
.schedule-page::after { bottom: 42px; }
.schedule-heading {
text-align: center; margin-bottom: 38px; position: relative; z-index: 1;
}
.schedule-title {
display: block; color: #a88c6b; font-size: 26px; line-height: 1.25;
letter-spacing: 0.28em; padding-left: 0.28em; font-weight: 700;
}
.schedule-kicker {
display: block; margin-top: 10px; color: #8A8680; font-size: 10px;
letter-spacing: 0.4em; padding-left: 0.4em; text-transform: uppercase;
}
.schedule-list {
width: min(100%, 360px); margin: 0 auto; position: relative;
display: flex; flex-direction: column; gap: 24px;
}
.schedule-list::before {
content: ''; position: absolute; top: 16px; bottom: 16px; left: 104px;
width: 1px; background: linear-gradient(180deg, transparent, rgba(168,140,107,0.54) 12%, rgba(168,140,107,0.54) 88%, transparent);
}
.schedule-row {
position: relative; display: grid; grid-template-columns: 78px 52px 1fr;
align-items: center; min-height: 68px;
}
.schedule-time {
justify-self: end; min-width: 72px; padding: 9px 10px 8px;
color: #8f7250; font-family: var(--font-number); font-size: 19px;
letter-spacing: 0.08em; text-align: center;
border-top: 1px solid rgba(168,140,107,0.28);
border-bottom: 1px solid rgba(168,140,107,0.2);
background: rgba(255,255,255,0.26);
}
.schedule-axis {
position: relative; height: 100%; display: flex; align-items: center; justify-content: center;
}
.schedule-node {
width: 18px; height: 18px; border-radius: 9999px; position: relative;
display: flex; align-items: center; justify-content: center;
background: #fbf7ef; border: 1px solid rgba(168,140,107,0.52);
box-shadow: 0 0 0 5px rgba(168,140,107,0.09), 0 6px 16px rgba(112,78,40,0.12);
}
.schedule-node i {
width: 6px; height: 6px; border-radius: 9999px; display: block;
background: #a88c6b;
}
.schedule-copy {
min-width: 0; padding: 13px 0 13px 2px; text-align: left;
border-bottom: 1px solid rgba(168,140,107,0.14);
}
.schedule-event {
display: block; color: #3f3428; font-family: var(--font-serif);
font-size: 16px; line-height: 1.4; letter-spacing: 0.16em; font-weight: 600;
}
.schedule-desc {
display: block; margin-top: 5px; color: #8A8680; font-size: 10px;
line-height: 1.5; letter-spacing: 0.2em; text-transform: uppercase;
}
/* ---------- 婚礼日历 ---------- */
.wedding-calendar-page {
min-height: 76vh;
background:
linear-gradient(180deg, rgba(253,251,247,0.72), rgba(247,246,242,0.94)),
radial-gradient(circle at 18% 12%, rgba(232,201,210,0.18), transparent 30%),
radial-gradient(circle at 82% 82%, rgba(168,140,107,0.12), transparent 28%);
}
.calendar-card {
width: min(100%, 352px); margin: 0 auto; padding: 30px 24px 28px;
color: #4a3f30; text-align: center; position: relative;
background:
linear-gradient(135deg, rgba(255,255,255,0.66), rgba(255,255,255,0.28)),
repeating-linear-gradient(0deg, rgba(112,82,47,0.025) 0 1px, transparent 1px 8px),
rgba(253,251,247,0.72);
border: 1px solid rgba(168,140,107,0.24);
box-shadow: 0 18px 50px rgba(95,70,39,0.13), inset 0 0 0 8px rgba(255,255,255,0.34);
}
.calendar-card::before {
content: ''; position: absolute; inset: 12px;
border: 1px solid rgba(168,140,107,0.16); pointer-events: none;
}
.calendar-kicker {
color: #a88c6b; font-family: var(--font-display);
font-size: 10px; letter-spacing: 0.46em; padding-left: 0.46em; text-transform: uppercase;
}
.calendar-title {
display: flex; align-items: center; justify-content: center; gap: 14px;
margin-top: 12px; color: #3f3428; font-family: var(--font-display);
font-size: 30px; line-height: 1.2; letter-spacing: 0.12em;
}
.calendar-title i {
width: 34px; height: 1px; background: linear-gradient(90deg, transparent, rgba(168,140,107,0.8), transparent);
}
.calendar-date-line {
margin: 10px auto 22px; color: #806f5c; font-size: 11px;
letter-spacing: 0.16em; font-family: var(--font-serif);
}
.calendar-weekdays,
.calendar-grid {
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px;
}
.calendar-weekdays {
margin-bottom: 9px; color: #a88c6b; font-size: 10px;
font-family: var(--font-kai); font-weight: 700;
}
.calendar-day {
position: relative; aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
color: #4b443c; font-family: var(--font-number); font-size: 15px;
}
.calendar-day.is-empty { visibility: hidden; }
.calendar-day-num {
position: relative; z-index: 4;
}
.calendar-day.is-selected .calendar-day-num {
width: 24px; height: 24px; border-radius: 9999px;
display: flex; align-items: center; justify-content: center;
color: #763342; font-weight: 900; font-size: 16px;
background: rgba(253,251,247,0.9);
box-shadow: 0 0 0 1px rgba(151,64,82,0.12), 0 4px 10px rgba(91,47,36,0.1);
text-shadow: 0 1px 0 rgba(255,255,255,0.82);
}
.heart-ring {
position: absolute; inset: -9px; display: flex; align-items: center; justify-content: center;
z-index: 1; animation: heartBeat 1.8s ease-in-out infinite;
filter: drop-shadow(0 5px 9px rgba(110,38,48,0.16));
}
.heart-ring svg {
width: 62px; height: 60px; overflow: visible;
}
.heart-brush {
fill: none;
stroke: #8f354a;
stroke-width: 12.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 310;
stroke-dashoffset: 310;
animation: brushHeartDraw 1.55s cubic-bezier(0.16, 0.82, 0.22, 1) forwards;
}
.heart-brush-shadow {
stroke: rgba(72,38,29,0.18);
stroke-width: 16;
transform: translate(2px, 3px) rotate(-2.2deg);
animation-delay: .06s;
}
.heart-brush-main {
stroke: #91364a;
}
.heart-brush-tail {
stroke: rgba(126,42,58,0.9);
stroke-width: 7.8;
stroke-dasharray: 58;
stroke-dashoffset: 58;
animation: brushTailDraw .62s cubic-bezier(0.18, 0.78, 0.24, 1) 1.08s forwards;
}
.heart-brush-cuts {
opacity: 0;
animation: dryBrushFlicker .42s ease-out .88s forwards;
}
.heart-brush-cuts path {
fill: none;
stroke: rgba(253,251,247,0.82);
stroke-width: 2.3;
stroke-linecap: round;
stroke-dasharray: 13 9;
}
.heart-brush-cuts path:nth-child(2) { stroke-width: 3; }
.heart-brush-cuts path:nth-child(3) { stroke-width: 2; opacity: .9; }
.heart-brush-cuts path:nth-child(4) { stroke-width: 1.8; opacity: .74; }
.heart-brush-speckles {
fill: rgba(120,42,56,0.56);
opacity: 0;
animation: inkDotIn .42s ease-out .72s forwards;
}
.calendar-note {
display: flex; align-items: center; justify-content: center; gap: 10px;
margin-top: 24px; color: #806f5c; font-family: var(--font-kai);
font-size: 13px; font-weight: 700; letter-spacing: 0.2em;
}
.calendar-note i {
width: 20px; height: 1px; background: rgba(168,140,107,0.45);
}
/* ---------- 入场揭幕(雅致象牙金) ---------- */
.curtain-root { position: fixed; inset: 0; z-index: 100; overflow: hidden; cursor: pointer; font-family: var(--font-sans); background: #fbf6ef; perspective: 1200px; }
.curtain-bg {
position: absolute; inset: -24px; background-size: cover; background-position: center;
filter: blur(18px) brightness(0.68) saturate(1.02); transform: scale(1.12);
}
.curtain-veil {
position: absolute; inset: 0;
background:
radial-gradient(circle at 50% 18%, rgba(255,250,242,0.76), rgba(241,226,207,0.36) 38%, rgba(82,60,42,0.2) 100%),
linear-gradient(180deg, rgba(251,246,239,0.42), rgba(232,214,194,0.28));
}
.curtain-door {
position: absolute; top: 0; bottom: 0; width: 50%; z-index: 2;
background:
linear-gradient(90deg, rgba(92,64,42,0.06), transparent 18%, transparent 78%, rgba(255,255,255,0.28)),
repeating-linear-gradient(90deg, rgba(166,128,87,0.04) 0 1px, transparent 1px 10px),
linear-gradient(160deg, #fbf6ef 0%, #f5e9dc 45%, #ecd9c4 100%);
box-shadow: inset 0 0 58px rgba(184,154,120,0.16), inset 0 0 0 1px rgba(255,255,255,0.42);
transition: transform 1.4s cubic-bezier(0.76, 0, 0.24, 1);
transform-style: preserve-3d;
}
.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::after {
content: ''; position: absolute; inset: 20px 18px;
border-top: 1px solid rgba(168,140,107,0.14);
border-bottom: 1px solid rgba(168,140,107,0.12);
pointer-events: none;
}
.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.56) 0%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, rgba(255,255,255,0.28) 100%); }
.curtain-root.is-opening .curtain-door-left { transform: translateX(-100%) rotateY(-12deg); }
.curtain-root.is-opening .curtain-door-right { transform: translateX(100%) rotateY(12deg); }
.curtain-emblem {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 3; padding: 28px;
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(0.98); }
.curtain-root.is-opening .curtain-veil { animation: veilFade 1.2s ease both; }
.curtain-root.is-opening.intro-exit-wind .curtain-emblem {
opacity: 1; transform: none;
}
.curtain-root.is-opening.intro-exit-wind .curtain-door-left { transform: translateX(-82%) rotateY(-8deg); }
.curtain-root.is-opening.intro-exit-wind .curtain-door-right { transform: translateX(82%) rotateY(8deg); }
.curtain-root.is-opening.intro-exit-wind .emblem-inner { animation: inviteContentExit 520ms cubic-bezier(0.4, 0, 1, 1) 90ms forwards; }
.curtain-root.is-opening.intro-exit-wind .invite-cover {
transform-origin: 72% 24%;
will-change: transform, opacity, filter;
animation: inviteWindExit 1180ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.curtain-root.is-opening.intro-exit-book .curtain-emblem {
opacity: 1; transform: none; perspective: 1500px;
}
.curtain-root.is-opening.intro-exit-book .curtain-bg,
.curtain-root.is-opening.intro-exit-book .curtain-veil,
.curtain-root.is-opening.intro-exit-book .curtain-door {
animation: bookBackdropFade 280ms ease-out forwards;
}
.curtain-root.is-opening.intro-exit-book .curtain-door-left,
.curtain-root.is-opening.intro-exit-book .curtain-door-right {
transform: none;
}
.curtain-root.is-opening.intro-exit-book .emblem-inner { animation: inviteContentExit 440ms cubic-bezier(0.4, 0, 1, 1) 330ms forwards; }
.curtain-root.is-opening.intro-exit-book .invite-cover {
transform-style: preserve-3d;
transform-origin: 0% 0%;
backface-visibility: hidden;
will-change: transform, opacity, filter, box-shadow;
animation: inviteBookExit 1220ms cubic-bezier(0.4, 0, 1, 1) 280ms forwards;
}
.curtain-root.is-opening.intro-exit-book .invite-cover::before {
animation: bookCurlShadow 1220ms cubic-bezier(0.4, 0, 1, 1) 280ms forwards;
}
.curtain-root.is-opening.intro-exit-book .invite-cover::after {
animation: bookCornerCurl 1220ms cubic-bezier(0.4, 0, 1, 1) 280ms forwards;
}
.curtain-root.is-opening.intro-exit-lift .curtain-emblem {
opacity: 1; transform: none;
}
.curtain-root.is-opening.intro-exit-lift .emblem-inner { animation: inviteContentExit 420ms cubic-bezier(0.4, 0, 1, 1) 50ms forwards; }
.curtain-root.is-opening.intro-exit-lift .invite-cover {
transform-origin: 50% 18%;
will-change: transform, opacity, filter;
animation: inviteLiftExit 900ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.curtain-root.is-opening.intro-exit-doors .curtain-emblem {
opacity: 1; transform: none;
}
.curtain-root.is-opening.intro-exit-doors .emblem-inner { animation: inviteContentExit 430ms cubic-bezier(0.4, 0, 1, 1) forwards; }
.curtain-root.is-opening.intro-exit-doors .invite-cover {
animation: inviteDoorsExit 860ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.invite-cover {
position: relative; width: min(82vw, 360px); min-height: min(72vh, 560px);
display: flex; align-items: center; justify-content: center; padding: 34px 24px;
color: #5b4a36; text-align: center;
background:
linear-gradient(135deg, rgba(255,255,255,0.5), transparent 28%, rgba(183,145,96,0.08) 100%),
repeating-linear-gradient(0deg, rgba(120,88,55,0.025) 0 1px, transparent 1px 7px),
#fbf7ef;
border: 1px solid rgba(168,140,107,0.35);
box-shadow: 0 28px 70px rgba(59,39,22,0.24), inset 0 0 0 10px rgba(255,255,255,0.34), inset 0 0 0 11px rgba(168,140,107,0.14);
}
.invite-cover::before,
.invite-cover::after {
content: ''; position: absolute; pointer-events: none;
}
.invite-cover::before {
inset: 18px; border: 1px solid rgba(168,140,107,0.25);
}
.invite-cover::after {
inset: 28px; border: 1px solid rgba(168,140,107,0.11);
background:
linear-gradient(45deg, transparent 0 49.6%, rgba(168,140,107,0.15) 50%, transparent 50.4%),
linear-gradient(-45deg, transparent 0 49.6%, rgba(168,140,107,0.12) 50%, transparent 50.4%);
opacity: .52;
}
.cover-filigree {
position: absolute; left: 50%; width: 122px; height: 22px; transform: translateX(-50%);
border-top: 1px solid rgba(168,140,107,0.42);
}
.cover-filigree::before,
.cover-filigree::after {
content: ''; position: absolute; top: -4px; width: 26px; height: 8px;
border-top: 1px solid rgba(168,140,107,0.42); border-radius: 50%;
}
.cover-filigree::before { left: 20px; transform: rotate(-18deg); }
.cover-filigree::after { right: 20px; transform: rotate(18deg); }
.cover-filigree-top { top: 42px; }
.cover-filigree-bottom { bottom: 42px; transform: translateX(-50%) rotate(180deg); }
.emblem-inner { position: relative; z-index: 1; width: 100%; text-align: center; color: #5b4a36; padding: 42px 16px 34px; }
.emblem-kicker { display: block; letter-spacing: 0.48em; font-size: 18px; color: #a88c6b; margin-bottom: 24px; padding-left: 0.48em; font-family: var(--font-calligraphy); font-weight: 700; }
.emblem-seal {
width: 56px; height: 56px; margin: 0 auto 22px; border-radius: 9999px;
display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(168,140,107,0.46); color: #a88c6b;
background: rgba(255,255,255,0.34); box-shadow: inset 0 0 0 5px rgba(168,140,107,0.07);
}
.emblem-seal i { font-size: 18px; }
.emblem-names { font-family: var(--font-calligraphy); font-size: clamp(25px, 8vw, 35px); line-height: 1.22; letter-spacing: 0.14em; font-weight: 700; color: #403527; }
.emblem-names .amp { font-style: italic; color: #b89a78; margin: 0 9px; font-family: var(--font-calligraphy); font-weight: 700; }
.emblem-rule { width: 72px; height: 1px; background: linear-gradient(90deg, transparent, #b89a78, transparent); margin: 22px auto 18px; }
.emblem-date { font-family: var(--font-calligraphy); font-size: 12px; letter-spacing: 0.34em; padding-left: 0.34em; color: #806a4d; }
.emblem-invite-title {
margin-top: 30px; color: #4a3f30; font-family: var(--font-calligraphy);
font-size: 24px; line-height: 1.35; letter-spacing: 0.1em; font-weight: 800;
}
.emblem-invite-copy {
max-width: 276px; margin: 13px auto 0; color: #5f503d;
font-family: var(--font-calligraphy); font-size: 15px; line-height: 2.05;
letter-spacing: 0.09em; font-weight: 600;
}
.emblem-hint { margin-top: 30px; font-size: 12px; letter-spacing: 0.26em; color: #9b825e;
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; font-family: var(--font-calligraphy); font-weight: 700;
}
.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: 700; font-family: var(--font-calligraphy);
}
.ending-names {
display: block; margin-top: 10px; color: #3A3A3A; font-size: 14px;
letter-spacing: 0.16em; font-family: var(--font-calligraphy); font-weight: 700;
}
.ending-date {
display: block; margin-top: 8px; color: #8A8680; font-size: 10px;
letter-spacing: 0.28em; padding-left: 0.28em; font-family: var(--font-number);
}
.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; font-family: var(--font-serif);
}
.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 按钮 ---------- */
.ending-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.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; }
.map-cta {
position: relative; width: 100%; min-height: 54px; border: 0;
border-top: 1px solid rgba(168,140,107,0.22);
border-bottom: 1px solid rgba(168,140,107,0.16);
background: rgba(255,255,255,0.2);
color: #7a6244; cursor: pointer; padding: 10px 2px;
display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px;
text-align: left;
transition: background .25s ease, border-color .25s ease, color .25s ease;
}
.map-cta:hover {
background: rgba(255,255,255,0.42);
border-color: rgba(168,140,107,0.34);
}
.map-cta-icon {
width: 28px; height: 28px; border-radius: 9999px;
display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(168,140,107,0.38); color: #a88c6b;
background: rgba(255,255,255,0.28);
}
.map-cta-icon i { font-size: 12px; }
.map-cta-copy {
display: flex; flex-direction: column; min-width: 0; gap: 3px;
}
.map-cta-copy b {
font-family: var(--font-serif); font-size: 13px; letter-spacing: 0.2em; font-weight: 600;
}
.map-cta-copy small {
color: #8A8680; font-size: 9px; letter-spacing: 0.08em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.map-cta-arrow {
color: #a88c6b; font-size: 10px; letter-spacing: 0.18em; padding: 0 1px 0 10px;
border-left: 1px solid rgba(168,140,107,0.2);
}
/* ---------- 地图选择 ---------- */
.map-sheet {
width: min(100%, 360px); border-radius: 20px; padding: 14px;
background: rgba(255,255,255,0.96); border: 1px solid rgba(168,140,107,0.18);
box-shadow: 0 20px 60px rgba(0,0,0,0.24); backdrop-filter: blur(14px);
}
.map-sheet-title {
text-align: center; color: #8a704f; font-size: 12px; letter-spacing: 0.22em;
padding: 8px 0 12px;
}
.map-sheet button {
width: 100%; min-height: 44px; border: none; border-radius: 12px; margin-top: 8px;
background: #Fdfbf7; color: #4a3f30; font-size: 13px; letter-spacing: 0.12em;
cursor: pointer; transition: background .2s ease, color .2s ease;
display: flex; align-items: center; justify-content: center; gap: 9px;
}
.map-sheet button i { width: 15px; color: #a88c6b; }
.map-sheet button:hover { background: rgba(168,140,107,0.14); color: #8a704f; }
.map-sheet .map-sheet-cancel { color: #8A8680; background: transparent; }
/* ---------- 微信内打开浏览器引导 ---------- */
.open-browser-mask {
position: fixed; inset: 0; z-index: 90;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
cursor: pointer;
}
.open-browser-arrow {
position: absolute; top: 8px; right: 18px;
width: 72px; height: 108px; color: #e8d5b5;
animation: open-browser-pulse 1.6s ease-in-out infinite;
}
.open-browser-arrow svg { width: 100%; height: 100%; display: block; }
.open-browser-copy {
position: absolute; top: 120px; left: 0; right: 0;
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 0 28px; text-align: center; pointer-events: none;
}
.open-browser-title {
color: #f3e7d3; font-size: 16px; letter-spacing: 0.28em;
font-family: var(--font-serif); font-weight: 500;
}
.open-browser-desc {
color: rgba(232, 213, 181, 0.88); font-size: 13px; letter-spacing: 0.14em; line-height: 1.7;
}
.open-browser-hint {
margin-top: 18px; color: rgba(168, 140, 107, 0.75); font-size: 10px; letter-spacing: 0.22em;
}
@keyframes open-browser-pulse {
0%, 100% { transform: translateY(0); opacity: 0.85; }
50% { transform: translateY(-6px); opacity: 1; }
}
/* ---------- 出席情况单选标签 ---------- */
.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-wrap { position: relative; }
.track-pop {
position: absolute; right: 46px; top: 0; width: 200px;
max-height: min(320px, calc(100vh - 3rem - 12px));
display: flex; flex-direction: column;
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 { flex-shrink: 0; font-size: 10px; color: #a88c6b; letter-spacing: 0.2em; padding: 4px 8px 6px; }
.track-pop-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.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; } }
@keyframes heartBeat {
0%, 100% { transform: scale(1); opacity: .92; }
18% { transform: scale(1.16); opacity: 1; }
34% { transform: scale(.96); }
52% { transform: scale(1.1); }
}
@keyframes brushHeartDraw {
0% { stroke-dashoffset: 310; opacity: .2; }
70% { stroke-dashoffset: 0; opacity: 1; }
100% { stroke-dashoffset: 0; opacity: 1; }
}
@keyframes brushTailDraw {
from { stroke-dashoffset: 58; opacity: .1; }
to { stroke-dashoffset: 0; opacity: .9; }
}
@keyframes dryBrushFlicker {
0% { opacity: 0; transform: scale(.98); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes inkDotIn {
from { opacity: 0; transform: scale(.4); }
to { opacity: 1; transform: scale(1); }
}
@keyframes veilFade {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes bookBackdropFade {
0% { opacity: 1; filter: blur(0); }
100% { opacity: 0; filter: blur(2px); }
}
@keyframes inviteContentExit {
0% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
100% { opacity: 0; transform: translateY(-7px) scale(.975); filter: blur(1px); }
}
@keyframes inviteWindExit {
0% { opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); filter: blur(0); }
8% { opacity: 1; transform: translate3d(-7px, 5px, 0) rotate(-1.4deg) scale(.982); filter: blur(0); }
28% { opacity: 1; transform: translate3d(20px, -18px, 0) rotate(4deg) scale(.985); filter: blur(0); }
58% { opacity: .82; transform: translate3d(46vw, -18vh, 0) rotate(13deg) scale(.93); filter: blur(.3px); }
100% { opacity: 0; transform: translate3d(96vw, -42vh, 0) rotate(24deg) scale(.82); filter: blur(2px); }
}
@keyframes inviteBookExit {
0% {
opacity: 1;
transform: translate3d(0, 0, 0) rotateX(0deg) rotateY(0deg) rotateZ(0deg) skew(0deg) scale(1);
filter: brightness(1) blur(0);
box-shadow: 0 28px 70px rgba(59,39,22,0.24), inset 0 0 0 10px rgba(255,255,255,0.34), inset 0 0 0 11px rgba(168,140,107,0.14);
}
10% {
opacity: 1;
transform: translate3d(4px, 5px, 0) rotateX(0deg) rotateY(0deg) rotateZ(.4deg) skew(.2deg) scale(.982, .992);
filter: brightness(.98) blur(0);
}
24% {
opacity: 1;
transform: translate3d(-10px, -14px, 42px) rotateX(-12deg) rotateY(18deg) rotateZ(-2deg) skew(1.2deg) scale(.992);
filter: brightness(1.04) blur(0);
box-shadow: 22px 30px 58px rgba(59,39,22,0.22), inset 14px 14px 24px rgba(87,57,31,0.1), inset 0 0 0 11px rgba(168,140,107,0.13);
}
48% {
opacity: .96;
transform: translate3d(-60px, -66px, 86px) rotateX(-32deg) rotateY(56deg) rotateZ(-8deg) skew(2.6deg) scale(.965);
filter: brightness(.96) blur(.15px);
box-shadow: 44px 42px 66px rgba(59,39,22,0.27), inset 28px 24px 36px rgba(87,57,31,0.18), inset 0 0 0 11px rgba(168,140,107,0.1);
}
74% {
opacity: .58;
transform: translate3d(-128px, -136px, 48px) rotateX(-48deg) rotateY(96deg) rotateZ(-15deg) skew(3.8deg) scale(.91);
filter: brightness(.88) blur(.7px);
}
100% {
opacity: 0;
transform: translate3d(-214px, -226px, -18px) rotateX(-60deg) rotateY(122deg) rotateZ(-22deg) skew(4.2deg) scale(.84);
filter: brightness(.82) blur(1.8px);
box-shadow: 56px 30px 44px rgba(59,39,22,0.1), inset 32px 32px 38px rgba(87,57,31,0.22);
}
}
@keyframes bookCurlShadow {
0%, 12% {
border-color: rgba(168,140,107,0.25);
box-shadow: none;
}
34% {
border-color: rgba(168,140,107,0.2);
box-shadow: inset 18px 16px 24px rgba(79,51,28,0.14), inset -8px 0 18px rgba(255,255,255,0.48);
}
72%, 100% {
border-color: rgba(168,140,107,0.08);
box-shadow: inset 28px 30px 38px rgba(79,51,28,0.18);
}
}
@keyframes bookCornerCurl {
0% {
opacity: .44;
clip-path: polygon(0 0, 0 0, 0 0);
background:
radial-gradient(circle at 0 0, rgba(255,255,255,0.9), rgba(236,219,198,0.5) 34%, rgba(93,58,32,0.22) 58%, transparent 62%),
linear-gradient(135deg, rgba(168,140,107,0.15) 0 1px, transparent 2px);
}
22% {
opacity: .96;
clip-path: polygon(0 0, 42% 0, 0 42%);
background:
radial-gradient(circle at 14% 14%, rgba(255,255,255,0.94), rgba(239,223,204,0.62) 34%, rgba(99,62,34,0.26) 60%, transparent 64%),
linear-gradient(135deg, rgba(255,255,255,0.42) 0 28%, rgba(92,58,31,0.2) 48%, transparent 57%);
}
52% {
opacity: .82;
clip-path: polygon(0 0, 64% 0, 0 64%);
background:
radial-gradient(circle at 24% 24%, rgba(255,255,255,0.78), rgba(239,223,204,0.56) 32%, rgba(84,50,28,0.34) 60%, transparent 65%),
linear-gradient(132deg, rgba(255,255,255,0.34), rgba(83,49,27,0.25) 50%, transparent 60%);
}
100% {
opacity: .18;
clip-path: polygon(0 0, 82% 0, 0 82%);
background: linear-gradient(132deg, rgba(255,255,255,0.28), rgba(72,45,26,0.32));
}
}
@keyframes inviteLiftExit {
0% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
10% { opacity: 1; transform: translateY(8px) scale(.975, .988); filter: blur(0); }
46% { opacity: .96; transform: translateY(-28px) scale(1.018, 1.03); filter: blur(0); }
100% { opacity: 0; transform: translateY(-116px) scale(1.08); filter: blur(2.4px); }
}
@keyframes inviteDoorsExit {
0% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
12% { opacity: 1; transform: translateY(4px) scale(.98); filter: blur(0); }
100% { opacity: 0; transform: translateY(-12px) scale(.94); filter: blur(1.4px); }
}
</style>