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

2809 lines
111 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-[100vh] 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 滚动赋值导致卡顿) -->
<div ref="scrollContainerRef"
class="w-full min-h-[100vh] no-scrollbar relative"
@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="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)"
:data-gallery-hold="isHoldGalleryType(page.type) ? String(pIdx) : undefined">
<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"
:activate="isHoldGalleryType(page.type) ? !!galleryActivate[pIdx] : false"
:nine-grid-animate="!!data.nineGridAnimate"
@preview-image="openImagePreview"
@ready="onGalleryReady(pIdx)"
/>
</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-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 { 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: '100vh',
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 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 HOLD_GALLERY_TYPES = new Set(['nine-grid', 'one-large-five-small'])
const isHoldGalleryType = (type) => HOLD_GALLERY_TYPES.has(type)
const galleryActivate = reactive({})
const galleryHold = ref(false)
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
let freeScrollLastTs = 0
let galleryObserver = null
let galleryRevealTimer = null
const galleryDone = new Set()
const BOTTOM_RETURN_DELAY = 30000
const FREE_SCROLL_BASE_STEP = 1.2
/** ready 后极短缓冲再恢复滚动(串行等待已在 PhotoGallery 内完成) */
const GALLERY_RESUME_DELAY_MS = 200
const freeInterval = computed(() => {
const v = Number(data.value.freeScrollInterval)
return v >= 10 && v <= 1000 ? v : 50
})
const freeScrollSignature = computed(() => `${data.value.scrollMode}:${freeInterval.value}`)
const restartAutoScroll = () => {
if (snapTimer || rafId) startAutoScroll()
}
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(${data.value.heroImg})` }))
const endImage = computed(() => 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) => {
const h = height || '100vh'
return h === 'auto' ? { minHeight: 'auto' } : { 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) => 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.img2)
if (Array.isArray(page.images)) images.push(...page.images)
})
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) => {
if (!src) return
await loadAlbumPreviewImages()
let images = albumPreviewImages.value.length ? albumPreviewImages.value : [src]
let index = images.indexOf(src)
if (index < 0) {
images = [src, ...images]
albumPreviewImages.value = images
index = 0
}
previewIndex.value = index
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 = () => window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
/** 缓存文档高度,避免每次 scroll 读 scrollHeight 触发强制重排 */
let cachedScrollMax = 0
let scrollRaf = 0
const refreshScrollMax = () => {
cachedScrollMax = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
return cachedScrollMax
}
const pageScrollMax = () => (cachedScrollMax > 0 ? cachedScrollMax : refreshScrollMax())
const handleScroll = () => {
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 = () => {
isInteracting.value = true
cancelAnimationFrame(animId)
clearTimeout(pauseTimer)
if (isAtBottom()) scheduleBottomReturn(true)
pauseTimer = setTimeout(() => (isInteracting.value = false), 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)
const start = pageScrollTop(), dist = target - start
if (!dist) return
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 = () => {
const top = pageScrollTop()
const next = sectionEls().find((e) => e.offsetTop > top + 12)
if (next) {
smoothScrollTo(next.offsetTop, switchDuration.value)
return
}
scheduleBottomReturn()
}
const canAutoAdvance = () =>
isAutoScroll.value
&& !isInteracting.value
&& !isDrawerOpen.value
&& !showBlessingDrawer.value
&& !previewVisible.value
&& !showMapOptions.value
&& !showOpenInBrowser.value
&& !galleryHold.value
const beginGalleryGate = (pIdx) => {
if (galleryDone.has(pIdx)) return
galleryDone.add(pIdx)
galleryHold.value = true
galleryActivate[pIdx] = true
}
const onGalleryReady = (pIdx) => {
if (!galleryDone.has(pIdx)) return
refreshScrollMax()
clearTimeout(galleryRevealTimer)
galleryRevealTimer = setTimeout(() => {
galleryHold.value = false
galleryRevealTimer = null
}, GALLERY_RESUME_DELAY_MS)
}
const bindGalleryHoldObserver = () => {
galleryObserver?.disconnect()
galleryObserver = null
if (typeof IntersectionObserver === 'undefined') return
const nodes = scrollContainerRef.value?.querySelectorAll('[data-gallery-hold]')
if (!nodes?.length) return
// 区块抵达「自下而上 75%」线后再停滚+activate(= 距视口顶 25%)
galleryObserver = new IntersectionObserver(
(entries) => {
const vh = window.innerHeight || 1
const lineY = vh * (1 - 0.75) // 从下往上 75%
for (const entry of entries) {
if (!entry.isIntersecting) continue
const rect = entry.boundingClientRect
// 区块跨越该水平线
if (rect.top > lineY || rect.bottom < lineY) continue
const raw = entry.target.getAttribute('data-gallery-hold')
if (raw == null) continue
const pIdx = Number(raw)
if (!Number.isFinite(pIdx) || galleryDone.has(pIdx)) continue
beginGalleryGate(pIdx)
}
},
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1], rootMargin: '0px' }
)
nodes.forEach((el) => galleryObserver.observe(el))
}
const startAutoScroll = () => {
clearTimeout(scrollStartTimer)
clearInterval(snapTimer)
clearInterval(freeTimer)
cancelAnimationFrame(rafId)
rafId = null
if (data.value.scrollMode === 'snap') {
if (canAutoAdvance()) goNextSection()
snapTimer = setInterval(() => {
if (canAutoAdvance()) goNextSection()
}, 4500)
} else {
freeScrollLastTs = 0
const tickFreeScroll = (now) => {
const dt = freeScrollLastTs ? Math.min(34, now - freeScrollLastTs) : 16.7
freeScrollLastTs = now
if (canAutoAdvance()) {
const top = pageScrollTop()
const max = pageScrollMax()
if (max - top <= 8) {
scheduleBottomReturn()
} else {
window.scrollTo(0, top + FREE_SCROLL_BASE_STEP * (dt / freeInterval.value))
}
}
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 = () => {
window.scrollTo(0, 0)
document.documentElement.scrollTop = 0
document.body.scrollTop = 0
}
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 toggleAutoScroll = () => {
isAutoScroll.value = !isAutoScroll.value
if (isAutoScroll.value && isAtBottom()) scheduleBottomReturn(true)
if (!isAutoScroll.value) clearBottomReturn()
}
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
data.value = { ...data.value, ...DEFAULTS, ...loaded }
}
} catch (e) { /* 使用默认配置 */ }
syncPageScrollMode()
albumLoaded.value = false
albumPreviewImages.value = localAlbumImages()
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()
bindGalleryHoldObserver()
refreshScrollMax()
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', refreshScrollMax, { passive: true })
handleScroll()
})
onUnmounted(() => {
document.documentElement.classList.remove('page-scroll-snap')
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', refreshScrollMax)
galleryObserver?.disconnect()
galleryObserver = null
clearTimeout(galleryRevealTimer)
galleryRevealTimer = 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()
bindGalleryHoldObserver()
refreshScrollMax()
},
{ deep: true }
)
watch(
() => data.value.nineGridAnimate,
async () => {
await nextTick()
bindGalleryHoldObserver()
}
)
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.page-scroll-snap) {
scroll-snap-type: y mandatory;
}
:global(html.page-scroll-snap body) {
scroll-snap-type: y mandatory;
}
.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;
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>