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