5068 lines
192 KiB
Vue
5068 lines
192 KiB
Vue
<template>
|
||
<div class="wrapper relative w-full min-h-[100dvh] bg-[var(--c-paper)] overflow-x-hidden text-[var(--c-ink)]">
|
||
|
||
<!-- 揭幕前 Loading:主题色囍字 + 雅致装饰 -->
|
||
<div
|
||
v-if="showBootLoading"
|
||
class="boot-loading"
|
||
:class="{ 'is-leaving': bootLeaving }"
|
||
aria-busy="true"
|
||
aria-label="加载中"
|
||
>
|
||
<div class="boot-decor" aria-hidden="true">
|
||
<i class="boot-glow boot-glow-1"></i>
|
||
<i class="boot-glow boot-glow-2"></i>
|
||
<i class="boot-glow boot-glow-3"></i>
|
||
<span class="boot-petal boot-petal-1">❀</span>
|
||
<span class="boot-petal boot-petal-2">❀</span>
|
||
<span class="boot-petal boot-petal-3">✦</span>
|
||
<span class="boot-petal boot-petal-4">✦</span>
|
||
<span class="boot-spark boot-spark-1"></span>
|
||
<span class="boot-spark boot-spark-2"></span>
|
||
<span class="boot-spark boot-spark-3"></span>
|
||
<span class="boot-spark boot-spark-4"></span>
|
||
<span class="boot-spark boot-spark-5"></span>
|
||
</div>
|
||
<div class="boot-frame boot-frame-tl" aria-hidden="true"></div>
|
||
<div class="boot-frame boot-frame-tr" aria-hidden="true"></div>
|
||
<div class="boot-frame boot-frame-bl" aria-hidden="true"></div>
|
||
<div class="boot-frame boot-frame-br" aria-hidden="true"></div>
|
||
|
||
<div class="boot-stage">
|
||
<span class="boot-kicker calligraphy">WEDDING INVITATION</span>
|
||
<div class="boot-rule" aria-hidden="true"><span></span><i>✦</i><span></span></div>
|
||
<div class="boot-seal-wrap">
|
||
<i class="boot-seal-ring boot-seal-ring-a" aria-hidden="true"></i>
|
||
<i class="boot-seal-ring boot-seal-ring-b" aria-hidden="true"></i>
|
||
<div class="boot-seal calligraphy">囍</div>
|
||
</div>
|
||
<div class="boot-rule boot-rule-flip" aria-hidden="true"><span></span><i>✦</i><span></span></div>
|
||
<p class="boot-names calligraphy-strong">{{ data.groom }}<span>&</span>{{ data.bride }}</p>
|
||
<p class="boot-hint calligraphy">请柬装载中</p>
|
||
<div class="boot-dots" aria-hidden="true"><i></i><i></i><i></i></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 入场揭幕帘幕(请柬封面) -->
|
||
<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">
|
||
<!-- 花瓣飘散退场的花瓣(仅 intro-exit-petal 播放时显示) -->
|
||
<span v-for="n in 7" :key="`pt${n}`" class="petal-piece" :class="`pp-${n}`" aria-hidden="true"></span>
|
||
<div class="invite-cover">
|
||
<!-- 信封启封模式的翻盖 + 蜡封(仅 intro-exit-envelope 显示) -->
|
||
<span class="envelope-flap" aria-hidden="true">
|
||
<i class="envelope-seal calligraphy">囍</i>
|
||
</span>
|
||
<!-- 贺卡掀页模式:纸背面 + 柔皱光影层(仅 intro-exit-paper 显示) -->
|
||
<span class="paper-back" aria-hidden="true"></span>
|
||
<span class="paper-fold" aria-hidden="true"></span>
|
||
<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">
|
||
<!-- 新中式:月洞门帖 -->
|
||
<template v-if="siteThemeKey === 'guofeng-new'">
|
||
<span class="em-gn-kicker">谨 呈 · 台 启</span>
|
||
<i class="em-gn-line" aria-hidden="true"></i>
|
||
<div class="em-gn-names">{{ data.groom }}<span class="em-gn-seal">囍</span>{{ data.bride }}</div>
|
||
<div class="em-gn-date">{{ data.date }} · {{ data.lunar }}</div>
|
||
<div class="em-gn-copy">谨定良辰,敬备喜筵,恭候台光</div>
|
||
<div class="emblem-hint"><span class="dot"></span>轻触启帖</div>
|
||
</template>
|
||
<!-- 老中式:红金婚书 -->
|
||
<template v-else-if="siteThemeKey === 'guofeng-old'">
|
||
<span class="em-go-top" aria-hidden="true">❖ ❖ ❖</span>
|
||
<div class="em-go-xi calligraphy-strong">囍</div>
|
||
<div class="em-go-title">婚 书</div>
|
||
<div class="em-go-names">{{ data.groom }} 偕 {{ data.bride }}</div>
|
||
<div class="em-go-date">谨定于 {{ data.date }}</div>
|
||
<div class="em-go-date em-go-lunar">{{ data.lunar }} 恭候光临</div>
|
||
<div class="emblem-hint"><span class="dot"></span>轻触展开婚书</div>
|
||
</template>
|
||
<!-- 杂志封面:刊头式幕布 -->
|
||
<template v-else-if="siteThemeKey === 'magazine'">
|
||
<span class="em-mag-logo">VOWS</span>
|
||
<span class="em-mag-issue">WEDDING ISSUE · {{ data.date }}</span>
|
||
<div class="em-mag-names">{{ data.groom }}<i>&</i>{{ data.bride }}</div>
|
||
<div class="em-mag-bar" aria-hidden="true"></div>
|
||
<div class="em-mag-copy">A LOVE STORY IN PRINT</div>
|
||
<div class="emblem-hint"><span class="dot"></span>轻触翻开本期</div>
|
||
</template>
|
||
<!-- 日报编辑:号外报头式幕布 -->
|
||
<template v-else-if="siteThemeKey === 'newspaper'">
|
||
<span class="em-news-vol">— 号外 · EXTRA —</span>
|
||
<div class="em-news-mast">囍事日报</div>
|
||
<div class="em-news-rule" aria-hidden="true"></div>
|
||
<div class="em-news-head">{{ data.groom }} 与 {{ data.bride }} 完婚在即</div>
|
||
<div class="em-news-date">{{ data.date }} · {{ data.lunar }}</div>
|
||
<div class="emblem-hint"><span class="dot"></span>轻触阅读头版</div>
|
||
</template>
|
||
<!-- 复古像素:游戏标题屏式幕布 -->
|
||
<template v-else-if="siteThemeKey === 'pixel'">
|
||
<span class="em-px-top">WEDDING QUEST</span>
|
||
<div class="em-px-heart" aria-hidden="true">♥</div>
|
||
<div class="em-px-names">{{ data.groom }} × {{ data.bride }}</div>
|
||
<div class="em-px-date">{{ data.date }}</div>
|
||
<div class="em-px-start">- PRESS START -</div>
|
||
</template>
|
||
<!-- 极简主义:轴线幕布 -->
|
||
<template v-else-if="siteThemeKey === 'zen'">
|
||
<span class="em-zen-kicker">INVITATION</span>
|
||
<i class="em-zen-line" aria-hidden="true"></i>
|
||
<div class="em-zen-names">{{ data.groom }} <em>/</em> {{ data.bride }}</div>
|
||
<div class="em-zen-date">{{ data.date }}</div>
|
||
<div class="emblem-hint"><span class="dot"></span>轻触开启</div>
|
||
</template>
|
||
<!-- 经典(风格包共用;印章符号随主题变) -->
|
||
<template v-else>
|
||
<span class="emblem-kicker first-letter:uppercase lowercase calligraphy">WEDDING INVITATION</span>
|
||
<div class="emblem-seal" aria-hidden="true">
|
||
<span v-if="curtainSealChar" class="emblem-seal-char">{{ curtainSealChar }}</span>
|
||
<i v-else 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>
|
||
</template>
|
||
</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 && showDanmaku"
|
||
:show-time="!!data.danmakuShowTime"
|
||
:area="danmakuArea"
|
||
: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-[80] flex flex-col items-end gap-3 pointer-events-auto">
|
||
<div @click.stop="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300"
|
||
:class="isAutoScroll ? 'bg-[var(--c-primary)]/15 !border-[var(--c-primary)]/60 shadow-sm' : 'bg-[var(--c-card)]/60 shadow-sm border-[var(--c-primary)]/30'"
|
||
title="自动滚动">
|
||
<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 class="relative">
|
||
<button
|
||
type="button"
|
||
class="control-btn cursor-pointer transition-all duration-300"
|
||
:class="showDanmaku ? 'bg-[var(--c-primary)]/15 !border-[var(--c-primary)]/60 shadow-sm' : 'bg-[var(--c-card)]/60 shadow-sm border-[var(--c-primary)]/30 opacity-55'"
|
||
:title="showDanmaku ? '关闭弹幕(长按设范围)' : '开启弹幕(长按设范围)'"
|
||
style="touch-action: manipulation; user-select: none; -webkit-user-select: none;"
|
||
@click.stop="onDanmakuBtnClick"
|
||
@touchstart.prevent="onDanmakuPressStart"
|
||
@touchend="onDanmakuPressEnd"
|
||
@touchcancel="onDanmakuPressCancel"
|
||
@mousedown="onDanmakuPressStart"
|
||
@mouseup="onDanmakuPressEnd"
|
||
@mouseleave="onDanmakuPressCancel"
|
||
>
|
||
<i class="fa-solid fa-comment-dots text-[13px]" :class="showDanmaku ? 'text-[var(--c-primary)]' : 'text-[var(--c-primary)]/70'"></i>
|
||
</button>
|
||
<div
|
||
v-if="showDanmakuAreaMenu"
|
||
class="danmaku-area-menu"
|
||
@click.stop
|
||
>
|
||
<button
|
||
v-for="opt in danmakuAreaOptions"
|
||
:key="opt.value"
|
||
type="button"
|
||
class="danmaku-area-opt"
|
||
:class="{ active: danmakuArea === opt.value }"
|
||
:title="opt.title"
|
||
@click="selectDanmakuArea(opt.value)"
|
||
>
|
||
<span class="danmaku-area-phone" aria-hidden="true">
|
||
<span class="danmaku-area-zone" :data-area="opt.value" />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="control-btn cursor-pointer transition-all duration-300"
|
||
:class="showGiftFx ? 'bg-[var(--c-primary)]/15 !border-[var(--c-primary)]/60 shadow-sm' : 'bg-[var(--c-card)]/60 shadow-sm border-[var(--c-primary)]/30 opacity-55'"
|
||
:title="showGiftFx ? '关闭礼物特效' : '开启礼物特效'"
|
||
@click.stop="toggleShowGiftFx"
|
||
>
|
||
<i class="fa-solid fa-gift text-[13px]" :class="showGiftFx ? 'text-[var(--c-primary)]' : 'text-[var(--c-primary)]/70'"></i>
|
||
</button>
|
||
</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">
|
||
<div
|
||
v-if="showLikeBubbles"
|
||
class="like-bubbles"
|
||
@click.stop
|
||
@touchstart.stop
|
||
>
|
||
<button
|
||
v-for="(n, i) in likeQuickAmounts"
|
||
:key="n"
|
||
type="button"
|
||
class="action-pill like-fab like-bubble-btn"
|
||
:style="likeBubbleStyle(i)"
|
||
:disabled="likeBusy"
|
||
@click.stop="handleQuickLike(n)"
|
||
>
|
||
<i class="fa-solid fa-heart like-icon"></i>
|
||
<span class="like-count">+{{ n }}</span>
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="action-pill like-fab"
|
||
:class="{ liked: likePulse, busy: likeBusy }"
|
||
:disabled="likeBusy"
|
||
title="点赞(长按快捷次数)"
|
||
@click="handleLikeClick"
|
||
@touchstart="onLikePressStart"
|
||
@touchend="onLikePressEnd"
|
||
@touchcancel="onLikePressCancel"
|
||
@mousedown="onLikePressStart"
|
||
@mouseup="onLikePressEnd"
|
||
@mouseleave="onLikePressCancel"
|
||
@contextmenu.prevent
|
||
>
|
||
<i class="fa-heart like-icon" :class="likePulse ? 'fa-solid' : 'fa-regular'"></i>
|
||
<span class="like-count">{{ likeCount }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- 行1.5:礼物 -->
|
||
<div class="action-dock-row">
|
||
<button
|
||
type="button"
|
||
class="action-pill gift-fab"
|
||
:class="{ busy: giftBusy }"
|
||
title="送礼物"
|
||
@click="openGiftPanel"
|
||
>
|
||
<i class="fa-solid fa-gift gift-icon"></i>
|
||
<span class="like-count">{{ giftTotal || '礼物' }}</span>
|
||
</button>
|
||
</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; openRsvpDrawer()"
|
||
>
|
||
<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" />
|
||
<GiftEffect v-if="giftFxBackend === 'classic'" ref="giftEffectRef" @play-start="onGiftPlayStart" />
|
||
<GiftEffect3D v-else ref="giftEffectRef" @play-start="onGiftPlayStart" @fallback="onGiftFxFallback" />
|
||
<GiftFeed ref="giftFeedRef" />
|
||
|
||
<!-- 礼物面板 -->
|
||
<div v-if="showGiftPanel" class="fixed inset-0 z-[60] bg-black/25 transition-opacity duration-200" @click="closeGiftPanel"></div>
|
||
<div
|
||
class="gift-panel fixed bottom-0 left-0 right-0 mx-auto w-full max-w-[420px] rounded-t-2xl shadow-xl transition-transform duration-300 z-[70] px-3 pt-3 pb-4 flex flex-col"
|
||
:class="{ 'pointer-events-none': !showGiftPanel }"
|
||
:style="{ transform: showGiftPanel ? 'translateY(0)' : 'translateY(110%)' }"
|
||
>
|
||
<div class="gift-panel-handle" aria-hidden="true"></div>
|
||
<div class="flex items-center justify-between mb-1.5 px-0.5">
|
||
<span class="text-[12px] text-[#c8c4bc] tracking-[0.25em]">送一份心意</span>
|
||
<button type="button" class="p-1 text-[#c8c4bc]/80 text-sm leading-none" @click="closeGiftPanel">✕</button>
|
||
</div>
|
||
<div class="gift-quota mb-1.5">
|
||
<span>可用点赞 <b>{{ giftQuota.available }}</b></span>
|
||
<span class="gift-quota-sub">总赞 {{ giftQuota.my_likes }} · 已兑 {{ giftQuota.spent }} · 已送 {{ giftQuota.gift_used }} 份</span>
|
||
</div>
|
||
<input
|
||
v-model="giftForm.name"
|
||
placeholder="您的姓名"
|
||
class="gift-name-input w-full rounded-lg px-3 py-2 text-[12px] mb-1.5 focus:outline-none"
|
||
/>
|
||
<div
|
||
ref="giftPagesRef"
|
||
class="gift-pages"
|
||
@scroll.passive="onGiftPagesScroll"
|
||
>
|
||
<div
|
||
v-for="(page, pi) in giftPages"
|
||
:key="pi"
|
||
class="gift-page"
|
||
>
|
||
<button
|
||
v-for="g in page"
|
||
:key="g.key"
|
||
type="button"
|
||
class="gift-item"
|
||
:disabled="giftBusy || giftQuota.available < giftCostOf(g.key)"
|
||
@click="handleSendGift(g.key)"
|
||
>
|
||
<span
|
||
class="gift-item-preview calligraphy-strong"
|
||
:style="{ backgroundImage: g.preview }"
|
||
>{{ g.label }}</span>
|
||
<span class="gift-item-caption">{{ g.label }}</span>
|
||
<span class="gift-item-cost">{{ giftCostOf(g.key) }} 赞</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="gift-page-dots" aria-hidden="true">
|
||
<span
|
||
v-for="(_, pi) in giftPages"
|
||
:key="pi"
|
||
class="gift-page-dot"
|
||
:class="{ active: giftPageIndex === pi }"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 送礼未填姓名:弹出姓名模态框 -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="showGiftNameModal"
|
||
class="fixed inset-0 z-[80] bg-black/40 flex items-center justify-center px-5"
|
||
@click.self="closeGiftNameModal"
|
||
>
|
||
<div class="gift-name-modal w-full max-w-[360px] bg-[var(--c-card)] rounded-2xl shadow-2xl px-5 pt-5 pb-4">
|
||
<div class="text-center mb-4">
|
||
<div class="text-[15px] text-[var(--c-ink)] tracking-[0.12em]">留下您的姓名</div>
|
||
<div class="text-[11px] text-[var(--c-muted)] mt-1.5 leading-relaxed">送礼需要署名,方便新人知道心意来自谁</div>
|
||
</div>
|
||
<input
|
||
ref="giftNameInputRef"
|
||
v-model="giftNameDraft"
|
||
type="text"
|
||
maxlength="20"
|
||
placeholder="您的姓名"
|
||
class="w-full bg-[var(--c-paper)] border-[0.5px] border-[var(--c-primary)]/35 rounded-xl px-4 py-3 text-[14px] focus:outline-none focus:border-[var(--c-primary)]"
|
||
@keyup.enter="confirmGiftName"
|
||
/>
|
||
<div class="flex gap-2 mt-4">
|
||
<button
|
||
type="button"
|
||
class="flex-1 py-3 rounded-full text-[12px] tracking-[0.2em] border border-[var(--c-primary)]/30 text-[var(--c-muted)]"
|
||
@click="closeGiftNameModal"
|
||
>取消</button>
|
||
<button
|
||
type="button"
|
||
class="flex-1 py-3 rounded-full text-[12px] tracking-[0.2em] bg-[var(--c-primary)] text-[var(--c-on-primary)]"
|
||
:disabled="giftBusy"
|
||
@click="confirmGiftName"
|
||
>确认送礼</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- 主滚动区域(注意:不要加 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' : ''">
|
||
<component
|
||
:is="coverComponent"
|
||
:key="siteThemeKey"
|
||
:hero-img="heroDisplayImg"
|
||
:groom="data.groom"
|
||
:bride="data.bride"
|
||
:date="data.date"
|
||
:lunar="data.lunar"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 2. 邀请文案页(风格包=经典竖排;完整模板=各自专属排版) -->
|
||
<div class="page-section bg-[var(--c-bg)] items-center px-8 relative justify-center" :class="data.scrollMode === 'snap' ? 'snap-start' : ''" :style="formalPageHeightStyle">
|
||
<template v-if="!isLayoutTheme">
|
||
<div class="absolute top-0 left-0 w-32 h-32 border-l-[0.5px] border-t-[0.5px] border-[var(--c-primary)]/30 m-6"></div>
|
||
<div class="absolute bottom-0 right-0 w-32 h-32 border-r-[0.5px] border-b-[0.5px] border-[var(--c-primary)]/30 m-6"></div>
|
||
</template>
|
||
<!-- 新中式:竖排 + 窗棂角饰 + 朱砂印 -->
|
||
<div v-if="siteThemeKey === 'guofeng-new'" v-reveal="'blur'" class="formal-gfnew">
|
||
<span class="fgn-corner fgn-corner-tl" aria-hidden="true"></span>
|
||
<span class="fgn-corner fgn-corner-br" aria-hidden="true"></span>
|
||
<div class="fgn-cols">
|
||
<span class="vertical-text fgn-main">{{ data.formalText1 }}</span>
|
||
<span class="vertical-text fgn-main fgn-main-2">{{ data.formalText2 }}</span>
|
||
<span class="vertical-text fgn-side">诚邀台驾 见证之喜</span>
|
||
</div>
|
||
<span class="fgn-seal" aria-hidden="true">囍</span>
|
||
</div>
|
||
<!-- 老中式:红底金框婚书卷 -->
|
||
<div v-else-if="siteThemeKey === 'guofeng-old'" v-reveal="'blur'" class="formal-gfold">
|
||
<div class="fgo-scroll">
|
||
<span class="fgo-head">— 喜 帖 —</span>
|
||
<div class="fgo-cols">
|
||
<span class="vertical-text fgo-main">{{ data.formalText1 }}</span>
|
||
<span class="vertical-text fgo-main fgo-main-2">{{ data.formalText2 }}</span>
|
||
</div>
|
||
<span class="fgo-foot">两 姓 联 姻 · 一 堂 缔 约</span>
|
||
</div>
|
||
</div>
|
||
<!-- 杂志:编辑部引言(pull-quote) -->
|
||
<div v-else-if="siteThemeKey === 'magazine'" v-reveal="'up'" class="formal-mag">
|
||
<span class="fm-quote" aria-hidden="true">“</span>
|
||
<p class="fm-text">{{ data.formalText1 }}</p>
|
||
<p class="fm-text fm-text-2">{{ data.formalText2 }}</p>
|
||
<div class="fm-foot"><i aria-hidden="true"></i><span>EDITOR'S NOTE · 诚挚邀请您见证</span></div>
|
||
</div>
|
||
<!-- 日报:结婚启事公告 -->
|
||
<div v-else-if="siteThemeKey === 'newspaper'" v-reveal="'up'" class="formal-news">
|
||
<div class="fn-title">结 婚 启 事</div>
|
||
<div class="fn-rule" aria-hidden="true"></div>
|
||
<p class="fn-body">{{ data.formalText1 }}</p>
|
||
<p class="fn-body">{{ data.formalText2 }}</p>
|
||
<div class="fn-sign">— {{ data.groom }} · {{ data.bride }} 敬启 —</div>
|
||
</div>
|
||
<!-- 像素:游戏对话框 -->
|
||
<div v-else-if="siteThemeKey === 'pixel'" v-reveal="'up'" class="formal-px">
|
||
<div class="fp-box">
|
||
<span class="fp-tag">SYSTEM</span>
|
||
<p class="fp-line">{{ data.formalText1 }}</p>
|
||
<p class="fp-line">{{ data.formalText2 }}</p>
|
||
<span class="fp-next" aria-hidden="true">▼</span>
|
||
</div>
|
||
</div>
|
||
<!-- 极简:横排轴线 -->
|
||
<div v-else-if="siteThemeKey === 'zen'" v-reveal="'up'" class="formal-zen">
|
||
<p>{{ data.formalText1 }}</p>
|
||
<i aria-hidden="true"></i>
|
||
<p>{{ data.formalText2 }}</p>
|
||
</div>
|
||
<!-- 经典竖排(风格包共用;页首符号随主题变) -->
|
||
<div v-else v-reveal="'blur'" class="flex flex-col items-center w-full">
|
||
<div class="text-3xl opacity-60 mb-12 text-[var(--c-primary)] font-serif float-soft">{{ formalMark }}</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-[var(--c-primary)]/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-[var(--c-card)]/40 -z-10"></div>
|
||
<div class="absolute top-0 left-0 w-24 h-24 border-l-[0.5px] border-t-[0.5px] border-[var(--c-primary)]/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-[var(--c-primary)]/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"
|
||
:layout-theme="isLayoutTheme"
|
||
:page-index="pIdx + 2"
|
||
@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">
|
||
<SectionHeading
|
||
v-if="isLayoutTheme"
|
||
:index="(data.photoPages?.length || 0) + 2"
|
||
kicker="Save The Date"
|
||
:title="`${calendarTitle.year} · ${calendarTitle.monthText}`"
|
||
/>
|
||
<template v-else>
|
||
<div class="calendar-kicker">Save The Date</div>
|
||
<div class="calendar-title">
|
||
<span>{{ calendarTitle.year }}</span>
|
||
<i></i>
|
||
<span>{{ calendarTitle.monthText }}</span>
|
||
</div>
|
||
</template>
|
||
<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' : ''">
|
||
<SectionHeading
|
||
v-if="isLayoutTheme"
|
||
v-reveal="'up'"
|
||
:index="(data.photoPages?.length || 0) + 3"
|
||
kicker="Wedding Schedule"
|
||
title="婚礼流程"
|
||
/>
|
||
<div v-else 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' : ''">
|
||
<!-- 门框装饰仅风格包主题渲染;完整模板走各自的收尾排版 -->
|
||
<template v-if="!isLayoutTheme">
|
||
<div v-reveal="'blur'" class="ending-frame" aria-hidden="true">
|
||
<span class="ending-keystone">
|
||
<i class="ek-line"></i>
|
||
<span class="ek-mark">❀</span>
|
||
<i class="ek-line ek-line-r"></i>
|
||
</span>
|
||
<span class="ending-vine ending-vine-l"></span>
|
||
<span class="ending-vine ending-vine-r"></span>
|
||
</div>
|
||
<div v-reveal="{ variant: 'scale', delay: 220 }" class="ending-corners" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||
</template>
|
||
<div class="w-full max-w-[340px] flex flex-col items-center z-10 relative">
|
||
<SectionHeading v-if="isLayoutTheme" v-reveal="'down'" title="With Love" kicker="Ending" />
|
||
<div v-else 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-[var(--c-bg)] 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="openRsvpDrawer">
|
||
<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 v-if="isLayoutTheme" v-reveal="{ variant: 'up', delay: 360 }" class="ending-layout-foot" aria-hidden="true">
|
||
<span v-if="siteThemeKey === 'guofeng-new'" class="elf elf-gfnew">佳期如许 <b>囍</b> 静候光临</span>
|
||
<span v-else-if="siteThemeKey === 'guofeng-old'" class="elf elf-gfold">囍 · 百年好合 · 永结同心 · 囍</span>
|
||
<span v-else-if="siteThemeKey === 'newspaper'" class="elf elf-news">── 本版终 · THE END ──</span>
|
||
<span v-else-if="siteThemeKey === 'magazine'" class="elf elf-mag">BACK COVER · VOWS MAGAZINE</span>
|
||
<span v-else-if="siteThemeKey === 'pixel'" class="elf elf-pixel">♥ GAME CLEAR · THANK YOU FOR PLAYING ♥</span>
|
||
<span v-else class="elf elf-zen">fin</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 章节指示点(首屏隐藏,点按跳转对应章节) -->
|
||
<nav
|
||
v-if="sectionDotCount > 1"
|
||
class="section-dots"
|
||
:class="{ visible: currentSectionIdx > 0 && !showBootLoading }"
|
||
aria-label="章节导航"
|
||
>
|
||
<button
|
||
v-for="i in sectionDotCount"
|
||
:key="i"
|
||
type="button"
|
||
class="section-dot"
|
||
:class="{ active: currentSectionIdx === i - 1 }"
|
||
:aria-label="`第 ${i} 屏`"
|
||
@click="goToSection(i - 1)"
|
||
></button>
|
||
</nav>
|
||
|
||
<!-- 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-[var(--c-card)] 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-[var(--c-primary)] cursor-pointer hover:text-[var(--c-ink)] transition-colors text-xl">✕</div>
|
||
<span class="text-xl text-center text-[var(--c-primary)] mb-1 tracking-[0.3em] font-light mt-2">出席回执</span>
|
||
<span class="text-center text-[10px] text-[var(--c-muted)] mb-10 tracking-[0.4em]">敬 请 回 复</span>
|
||
|
||
<div v-if="!isSubmitted" class="space-y-6">
|
||
<div>
|
||
<span class="block text-[10px] text-[var(--c-primary)] mb-1.5 tracking-widest pl-1">姓名</span>
|
||
<input v-model="rsvpForm.name" placeholder="您的姓名" class="w-full bg-[var(--c-paper)] border-[0.5px] border-[var(--c-primary)]/30 rounded-xl px-5 py-4 text-[13px] focus:outline-none focus:border-[var(--c-primary)] shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]" />
|
||
</div>
|
||
<div>
|
||
<span class="block text-[10px] text-[var(--c-primary)] 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-[var(--c-primary)] 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-[var(--c-paper)] border-[0.5px] border-[var(--c-primary)]/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-[var(--c-primary)] text-[var(--c-on-primary)] py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[var(--c-primary-deep)] 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-[var(--c-primary)] mb-6 animate-pulse-heart">♥</span>
|
||
<span class="text-lg text-[var(--c-ink)] mb-3 tracking-[0.2em] font-light">感恩您的祝福</span>
|
||
<span class="text-[13px] text-[var(--c-muted)] 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-[var(--c-card)] 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-[var(--c-primary)] cursor-pointer hover:text-[var(--c-ink)] transition-colors text-xl">✕</div>
|
||
<span class="text-xl text-center text-[var(--c-primary)] mb-1 tracking-[0.3em] font-light mt-2">送上祝福</span>
|
||
<span class="text-center text-[10px] text-[var(--c-muted)] mb-8 tracking-[0.4em]">即 时 展 示</span>
|
||
<div v-if="!blessingSubmitted" class="space-y-6">
|
||
<div>
|
||
<span class="block text-[10px] text-[var(--c-primary)] mb-1.5 tracking-widest pl-1">姓名</span>
|
||
<input v-model="blessingForm.name" placeholder="您的姓名" class="w-full bg-[var(--c-paper)] border-[0.5px] border-[var(--c-primary)]/30 rounded-xl px-5 py-4 text-[13px] focus:outline-none focus:border-[var(--c-primary)]" />
|
||
</div>
|
||
<div class="relative">
|
||
<div class="flex items-center justify-between mb-1.5 pl-1 pr-0.5">
|
||
<span class="text-[10px] text-[var(--c-primary)] 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-[var(--c-paper)] border-[0.5px] border-[var(--c-primary)]/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-[var(--c-primary)] 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-[var(--c-primary)] 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-[var(--c-primary)] text-[var(--c-on-primary)] py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[var(--c-primary-deep)] transition-all shadow-md">发送祝福</div>
|
||
</div>
|
||
<div v-else class="text-center py-8 flex flex-col items-center">
|
||
<span class="text-3xl text-[var(--c-primary)] mb-5">♥</span>
|
||
<span class="text-lg text-[var(--c-ink)] mb-2 tracking-[0.2em] font-light">祝福已送达</span>
|
||
<span class="text-[13px] text-[var(--c-muted)] leading-loose tracking-widest font-light mb-8">已展示在弹幕中</span>
|
||
<button
|
||
type="button"
|
||
class="w-full max-w-[280px] bg-[var(--c-primary)] text-[var(--c-on-primary)] py-4 rounded-full tracking-[0.3em] text-[12px] hover:bg-[var(--c-primary-deep)] transition-all shadow-md"
|
||
@click="closeBlessingDrawer"
|
||
>确认</button>
|
||
<span class="mt-4 text-[11px] text-[var(--c-muted)] 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, defineAsyncComponent } from 'vue'
|
||
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike, getGiftStatus, submitGift } from '@/api/wedding'
|
||
import { useAudio } from '@/composables/useAudio'
|
||
import { toast } from '@/composables/useToast'
|
||
import {
|
||
pickMusicUrl, pickWeightedMusicUrl, rememberMusicPick, resolveMusicPlayMode,
|
||
} from '@/utils/musicPick'
|
||
import { getClientId } from '@/utils/clientId'
|
||
import { getGuestName, setGuestName } from '@/utils/guestName'
|
||
import {
|
||
GIFT_TYPES,
|
||
emptyGiftCounts,
|
||
emptyGiftCosts,
|
||
aggregateGiftRecent,
|
||
chunkGiftTypes,
|
||
LIKE_QUICK_AMOUNTS,
|
||
} from '@/utils/giftTypes'
|
||
import { sendVisitBeacon, getFingerprint } from '@/utils/visitBeacon'
|
||
import { optimizeImageUrl, isIOS } from '@/composables/useAndroidOptimize'
|
||
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
|
||
import { applySiteTheme, normalizeSiteTheme, themeLayoutKind } from '@/utils/themes'
|
||
import SectionHeading from '@/components/themes/SectionHeading.vue'
|
||
import CoverClassic from '@/components/themes/covers/CoverClassic.vue'
|
||
import CoverMagazine from '@/components/themes/covers/CoverMagazine.vue'
|
||
import CoverNewspaper from '@/components/themes/covers/CoverNewspaper.vue'
|
||
import CoverPixel from '@/components/themes/covers/CoverPixel.vue'
|
||
import CoverZen from '@/components/themes/covers/CoverZen.vue'
|
||
import CoverGuofengNew from '@/components/themes/covers/CoverGuofengNew.vue'
|
||
import CoverGuofengOld from '@/components/themes/covers/CoverGuofengOld.vue'
|
||
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
|
||
import CanvasEffects from '@/components/CanvasEffects.vue'
|
||
import DanmakuLayer from '@/components/DanmakuLayer.vue'
|
||
import { DANMAKU_AREA_OPTIONS, DEFAULT_DANMAKU_AREA } from '@/utils/danmakuArea'
|
||
import LikeBurst from '@/components/LikeBurst.vue'
|
||
import GiftEffect from '@/components/GiftEffect.vue'
|
||
import GiftFeed from '@/components/GiftFeed.vue'
|
||
// 3D 特效渲染器按需加载(three 依赖在组件内部再动态 import,不影响首屏)
|
||
const GiftEffect3D = defineAsyncComponent(() => import('@/components/GiftEffect3D.vue'))
|
||
import PhotoGallery from '@/components/PhotoGallery.vue'
|
||
|
||
const DEFAULTS = {
|
||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||
musicList: [], activeMusicUrl: '', musicPlayMode: 'random', musicRandomEnabled: true,
|
||
musicCacheEnabled: true, musicCacheHours: 24,
|
||
danmakuEnabled: true, danmakuShowTime: true, nineGridAnimate: false,
|
||
giftEffectMode: 'classic', // 礼物特效方案:classic 经典粒子 / three 立体炫效 / auto 按设备
|
||
siteTheme: 'champagne', // 整站主题:champagne/crimson/sage/ocean/lilac/sunset
|
||
|
||
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 showBootLoading = ref(true), bootLeaving = 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 currentMusicPlayMode = () => resolveMusicPlayMode(data.value)
|
||
|
||
const musicCacheOptions = () => ({
|
||
mode: currentMusicPlayMode(),
|
||
cacheEnabled: data.value.musicCacheEnabled !== false,
|
||
cacheHours: data.value.musicCacheHours,
|
||
})
|
||
|
||
const rememberIfCached = (url) => {
|
||
if (currentMusicPlayMode() === 'random' && data.value.musicCacheEnabled !== false && url) {
|
||
rememberMusicPick(url)
|
||
}
|
||
}
|
||
|
||
/** 随机模式下播完/下一首:按权重再抽,尽量避开当前曲 */
|
||
const playRandomTrack = async (excludeUrl) => {
|
||
const url = pickWeightedMusicUrl(data.value.musicList, {
|
||
excludeUrl: excludeUrl || audio.activeUrl.value,
|
||
})
|
||
if (!url) return
|
||
rememberIfCached(url)
|
||
await audio.playUrl(url)
|
||
}
|
||
|
||
const syncMusicPlaybackMode = () => {
|
||
// single → 原生循环;其余模式播完由 onEnded 接管
|
||
audio.setLoop(currentMusicPlayMode() === 'single')
|
||
}
|
||
|
||
const playPrevTrack = () => {
|
||
const mode = currentMusicPlayMode()
|
||
if (mode === 'reverse') {
|
||
audio.next()
|
||
} else {
|
||
audio.prev()
|
||
}
|
||
rememberIfCached(audio.activeUrl.value)
|
||
}
|
||
const playNextTrack = () => {
|
||
const mode = currentMusicPlayMode()
|
||
if (mode === 'random' && (data.value.musicList || []).filter((t) => t?.url).length > 1) {
|
||
playRandomTrack(audio.activeUrl.value)
|
||
return
|
||
}
|
||
if (mode === 'reverse') {
|
||
audio.prev()
|
||
} else {
|
||
audio.next()
|
||
}
|
||
rememberIfCached(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 giftEffectRef = ref(null)
|
||
const giftFeedRef = ref(null)
|
||
/** 礼物特效渲染后端:后台可配 classic / three / auto;WebGL 初始化失败自动回落 classic */
|
||
const giftFxFallback = ref(false)
|
||
const giftFxBackend = computed(() => {
|
||
if (giftFxFallback.value) return 'classic'
|
||
const mode = data.value.giftEffectMode || 'classic'
|
||
if (mode === 'three') return 'three'
|
||
if (mode === 'auto') {
|
||
const mem = navigator.deviceMemory
|
||
const cores = navigator.hardwareConcurrency || 4
|
||
return !isIOS && (mem == null || mem >= 4) && cores >= 4 ? 'three' : 'classic'
|
||
}
|
||
return 'classic'
|
||
})
|
||
const onGiftFxFallback = () => { giftFxFallback.value = true }
|
||
|
||
// 整站主题:跟随配置切换(含后台预览 postMessage 实时推送)
|
||
watch(() => data.value.siteTheme, (t) => applySiteTheme(t), { immediate: true })
|
||
/** 当前主题(归一化后)与布局类型(pack=风格包沿用经典排版 / layout=完整模板) */
|
||
const siteThemeKey = computed(() => normalizeSiteTheme(data.value.siteTheme))
|
||
const isLayoutTheme = computed(() => themeLayoutKind(siteThemeKey.value) === 'layout')
|
||
/** 封面组件:风格包共用经典封面,完整模板各用专属排版 */
|
||
const coverComponent = computed(() => ({
|
||
'guofeng-new': CoverGuofengNew,
|
||
'guofeng-old': CoverGuofengOld,
|
||
magazine: CoverMagazine,
|
||
newspaper: CoverNewspaper,
|
||
pixel: CoverPixel,
|
||
zen: CoverZen,
|
||
}[siteThemeKey.value] || CoverClassic))
|
||
/** 幕布印章符号:各风格包的辨识元素(空 = 默认羽毛图标) */
|
||
const curtainSealChar = computed(() => ({
|
||
french: '❀',
|
||
forest: '❧',
|
||
night: '☾',
|
||
modern: '◆',
|
||
}[siteThemeKey.value] || ''))
|
||
/** 邀请文案页首符号:随风格包变化 */
|
||
const formalMark = computed(() => ({
|
||
french: '✿',
|
||
forest: '❦',
|
||
night: '☾',
|
||
modern: '◇',
|
||
}[siteThemeKey.value] || '❀'))
|
||
const likeCount = ref(0)
|
||
const likePulse = ref(false)
|
||
const likeBusy = ref(false)
|
||
let likePulseTimer = null
|
||
const giftPages = chunkGiftTypes(GIFT_TYPES, 4)
|
||
const giftPagesRef = ref(null)
|
||
const giftPageIndex = ref(0)
|
||
const giftCounts = reactive(emptyGiftCounts())
|
||
const giftCosts = reactive(emptyGiftCosts())
|
||
const giftTotal = ref(0)
|
||
const giftBusy = ref(false)
|
||
const showGiftPanel = ref(false)
|
||
const giftForm = reactive({ name: '' })
|
||
const showGiftNameModal = ref(false)
|
||
const giftNameDraft = ref('')
|
||
const giftNameInputRef = ref(null)
|
||
const pendingGiftType = ref('')
|
||
const giftQuota = reactive({
|
||
my_likes: 0,
|
||
spent: 0,
|
||
available: 0,
|
||
gift_used: 0,
|
||
})
|
||
const pendingGiftReplay = ref([])
|
||
const likeQuickAmounts = LIKE_QUICK_AMOUNTS
|
||
const showLikeBubbles = ref(false)
|
||
let likePressTimer = null
|
||
let likePressFired = false
|
||
let likeSkipClick = false
|
||
let likeBubblesHideTimer = null
|
||
|
||
/** 气泡绕点赞左侧半圆弧分布(上 → 左 → 下) */
|
||
const likeBubbleStyle = (index) => {
|
||
const total = likeQuickAmounts.length || 1
|
||
const startDeg = 132
|
||
const endDeg = 228
|
||
const deg = total === 1 ? 180 : startDeg + ((endDeg - startDeg) * index) / (total - 1)
|
||
const rad = (deg * Math.PI) / 180
|
||
const r = 78
|
||
const x = Math.cos(rad) * r
|
||
const y = -Math.sin(rad) * r
|
||
return {
|
||
'--bx': `${x.toFixed(1)}px`,
|
||
'--by': `${y.toFixed(1)}px`,
|
||
'--delay': `${0.04 + index * 0.05}s`,
|
||
}
|
||
}
|
||
|
||
const giftCostOf = (key) => Number(giftCosts[key]) || Number(GIFT_TYPES.find((g) => g.key === key)?.cost) || 50
|
||
|
||
const applyGiftQuota = (quota) => {
|
||
if (!quota || typeof quota !== 'object') return
|
||
giftQuota.my_likes = Number(quota.my_likes) || 0
|
||
giftQuota.spent = Number(quota.spent) || 0
|
||
giftQuota.available = Math.max(0, Number(quota.available) || 0)
|
||
giftQuota.gift_used = Number(quota.gift_used) || 0
|
||
if (quota.costs && typeof quota.costs === 'object') {
|
||
for (const g of GIFT_TYPES) {
|
||
const v = Number(quota.costs[g.key])
|
||
if (Number.isFinite(v) && v > 0) giftCosts[g.key] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
const onGiftPagesScroll = () => {
|
||
const el = giftPagesRef.value
|
||
if (!el) return
|
||
const w = el.clientWidth || 1
|
||
giftPageIndex.value = Math.round(el.scrollLeft / w)
|
||
}
|
||
|
||
const hideLikeBubblesSoon = () => {
|
||
clearTimeout(likeBubblesHideTimer)
|
||
likeBubblesHideTimer = setTimeout(() => {
|
||
showLikeBubbles.value = false
|
||
}, 3500)
|
||
}
|
||
|
||
const clearLikePressTimer = () => {
|
||
if (likePressTimer) {
|
||
clearTimeout(likePressTimer)
|
||
likePressTimer = null
|
||
}
|
||
}
|
||
|
||
const onLikePressStart = (e) => {
|
||
if (likeBusy.value) return
|
||
if (e.type === 'mousedown' && e.button !== 0) return
|
||
// 阻止 iOS 长按选字 / 呼出菜单;touch-action:manipulation 防连点放大
|
||
if (e.type === 'touchstart' && e.cancelable) e.preventDefault()
|
||
likePressFired = false
|
||
clearLikePressTimer()
|
||
likePressTimer = setTimeout(() => {
|
||
likePressFired = true
|
||
likeSkipClick = true
|
||
showLikeBubbles.value = true
|
||
hideLikeBubblesSoon()
|
||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||
try { navigator.vibrate(12) } catch { /* ignore */ }
|
||
}
|
||
}, 420)
|
||
}
|
||
|
||
const onLikePressEnd = (e) => {
|
||
const longPress = likePressFired
|
||
clearLikePressTimer()
|
||
// touchstart 已 preventDefault 时,部分机型不会再派发 click,短按在此补一次
|
||
if (e?.type === 'touchend' && !longPress) {
|
||
likeSkipClick = true
|
||
handleLike(1)
|
||
return
|
||
}
|
||
if (longPress) likeSkipClick = true
|
||
}
|
||
|
||
const onLikePressCancel = () => {
|
||
clearLikePressTimer()
|
||
}
|
||
|
||
const handleLikeClick = (e) => {
|
||
// 触摸已在 touchend 处理,忽略随后的合成 click
|
||
if (e?.pointerType === 'touch' || likeSkipClick || likePressFired) {
|
||
likeSkipClick = false
|
||
likePressFired = false
|
||
return
|
||
}
|
||
handleLike(1)
|
||
}
|
||
|
||
const handleQuickLike = (n) => {
|
||
showLikeBubbles.value = false
|
||
handleLike(n)
|
||
}
|
||
|
||
const DANMAKU_PREF_KEY = 'wedding_show_danmaku_v1'
|
||
const DANMAKU_AREA_KEY = 'wedding_danmaku_area_v1'
|
||
const GIFT_FX_PREF_KEY = 'wedding_show_gift_fx_v1'
|
||
const readPref = (key, fallback = true) => {
|
||
try {
|
||
const v = localStorage.getItem(key)
|
||
if (v === null) return fallback
|
||
return v !== '0' && v !== 'false'
|
||
} catch {
|
||
return fallback
|
||
}
|
||
}
|
||
const readDanmakuArea = () => {
|
||
try {
|
||
const v = localStorage.getItem(DANMAKU_AREA_KEY)
|
||
if (DANMAKU_AREA_OPTIONS.some((o) => o.value === v)) return v
|
||
} catch { /* ignore */ }
|
||
return DEFAULT_DANMAKU_AREA
|
||
}
|
||
const showDanmaku = ref(readPref(DANMAKU_PREF_KEY, true))
|
||
const showGiftFx = ref(readPref(GIFT_FX_PREF_KEY, true))
|
||
const danmakuArea = ref(readDanmakuArea())
|
||
const danmakuAreaOptions = DANMAKU_AREA_OPTIONS
|
||
const showDanmakuAreaMenu = ref(false)
|
||
let danmakuPressTimer = null
|
||
let danmakuPressFired = false
|
||
let danmakuSkipClick = false
|
||
let danmakuAreaMenuHideTimer = null
|
||
|
||
const hideDanmakuAreaMenuSoon = () => {
|
||
clearTimeout(danmakuAreaMenuHideTimer)
|
||
danmakuAreaMenuHideTimer = setTimeout(() => {
|
||
showDanmakuAreaMenu.value = false
|
||
}, 4500)
|
||
}
|
||
|
||
const clearDanmakuPressTimer = () => {
|
||
if (danmakuPressTimer) {
|
||
clearTimeout(danmakuPressTimer)
|
||
danmakuPressTimer = null
|
||
}
|
||
}
|
||
|
||
const onDanmakuPressStart = (e) => {
|
||
if (e.type === 'mousedown' && e.button !== 0) return
|
||
if (e.type === 'touchstart' && e.cancelable) e.preventDefault()
|
||
danmakuPressFired = false
|
||
clearDanmakuPressTimer()
|
||
danmakuPressTimer = setTimeout(() => {
|
||
danmakuPressFired = true
|
||
danmakuSkipClick = true
|
||
showDanmakuAreaMenu.value = true
|
||
hideDanmakuAreaMenuSoon()
|
||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||
try { navigator.vibrate(12) } catch { /* ignore */ }
|
||
}
|
||
}, 420)
|
||
}
|
||
|
||
const onDanmakuPressEnd = (e) => {
|
||
const longPress = danmakuPressFired
|
||
clearDanmakuPressTimer()
|
||
if (e?.type === 'touchend' && !longPress) {
|
||
danmakuSkipClick = true
|
||
toggleShowDanmaku()
|
||
return
|
||
}
|
||
if (longPress) danmakuSkipClick = true
|
||
}
|
||
|
||
const onDanmakuPressCancel = () => {
|
||
clearDanmakuPressTimer()
|
||
danmakuPressFired = false
|
||
}
|
||
|
||
const onDanmakuBtnClick = () => {
|
||
if (danmakuSkipClick) {
|
||
danmakuSkipClick = false
|
||
return
|
||
}
|
||
toggleShowDanmaku()
|
||
}
|
||
|
||
const selectDanmakuArea = (value) => {
|
||
if (!DANMAKU_AREA_OPTIONS.some((o) => o.value === value)) return
|
||
danmakuArea.value = value
|
||
try { localStorage.setItem(DANMAKU_AREA_KEY, value) } catch { /* ignore */ }
|
||
showDanmakuAreaMenu.value = false
|
||
// 选了范围后若弹幕关着,顺手打开便于预览
|
||
if (!showDanmaku.value) {
|
||
showDanmaku.value = true
|
||
try { localStorage.setItem(DANMAKU_PREF_KEY, '1') } catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
const toggleShowDanmaku = () => {
|
||
showDanmakuAreaMenu.value = false
|
||
showDanmaku.value = !showDanmaku.value
|
||
try { localStorage.setItem(DANMAKU_PREF_KEY, showDanmaku.value ? '1' : '0') } catch { /* ignore */ }
|
||
}
|
||
const toggleShowGiftFx = async () => {
|
||
const next = !showGiftFx.value
|
||
if (!next) {
|
||
// 先清特效/列表,再切开关,避免同帧 Transition 冲突
|
||
giftEffectRef.value?.clear?.()
|
||
giftFeedRef.value?.clear?.()
|
||
showGiftFx.value = false
|
||
try { localStorage.setItem(GIFT_FX_PREF_KEY, '0') } catch { /* ignore */ }
|
||
return
|
||
}
|
||
showGiftFx.value = true
|
||
try { localStorage.setItem(GIFT_FX_PREF_KEY, '1') } catch { /* ignore */ }
|
||
// 重新打开时拉取最近礼物并回放
|
||
await nextTick()
|
||
await loadGiftStatus()
|
||
}
|
||
|
||
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
|
||
const albumPreviewImages = ref([])
|
||
const rsvpForm = reactive({ name: getGuestName(), guest_count: '1', wishes: '' })
|
||
const blessingForm = reactive({ name: getGuestName(), 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 syncGuestNameIntoForms = () => {
|
||
const n = getGuestName()
|
||
if (!n) return
|
||
if (!rsvpForm.name.trim()) rsvpForm.name = n
|
||
if (!blessingForm.name.trim()) blessingForm.name = n
|
||
if (!giftForm.name.trim()) giftForm.name = n
|
||
}
|
||
|
||
const openRsvpDrawer = () => {
|
||
syncGuestNameIntoForms()
|
||
isDrawerOpen.value = true
|
||
}
|
||
|
||
const openBlessingDrawer = () => {
|
||
clearBlessingAutoClose()
|
||
syncGuestNameIntoForms()
|
||
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 isReturningToTop = false
|
||
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 heroDisplayImg = computed(() => optimizeImageUrl(data.value.heroImgResized || data.value.heroImg))
|
||
const curtainBg = computed(() => ({ backgroundImage: `url(${heroDisplayImg.value})` }))
|
||
const endImage = computed(() => optimizeImageUrl(
|
||
data.value.endImgResized
|
||
|| data.value.endImg
|
||
|| data.value.heroImgResized
|
||
|| data.value.heroImg,
|
||
))
|
||
const INTRO_EXIT_WHITELIST = ['wind', 'book', 'lift', 'doors', 'envelope', 'paper', 'zoom', 'iris', 'petal']
|
||
const introExitClass = computed(() => {
|
||
const effect = INTRO_EXIT_WHITELIST.includes(data.value.introExitEffect) ? data.value.introExitEffect : 'wind'
|
||
return `intro-exit-${effect}`
|
||
})
|
||
const introExitDuration = computed(() => ({
|
||
wind: 1280, book: 1680, lift: 980, doors: 1180,
|
||
envelope: 1780, paper: 1300, zoom: 1200, iris: 1320, petal: 1760,
|
||
}[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(() =>
|
||
!showBootLoading.value
|
||
&& !isDrawerOpen.value && !showBlessingDrawer.value && !showGiftPanel.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
|
||
)
|
||
|
||
const preloadImage = (url) => new Promise((resolve) => {
|
||
const src = String(url || '').trim()
|
||
if (!src) {
|
||
resolve()
|
||
return
|
||
}
|
||
const img = new Image()
|
||
img.onload = img.onerror = () => resolve()
|
||
img.src = optimizeImageUrl(src)
|
||
})
|
||
|
||
const finishBootLoading = async () => {
|
||
if (!showBootLoading.value || bootLeaving.value) return
|
||
bootLeaving.value = true
|
||
await new Promise((r) => setTimeout(r, 480))
|
||
showBootLoading.value = false
|
||
bootLeaving.value = false
|
||
}
|
||
|
||
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 || showGiftPanel.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()
|
||
const dist = target - start
|
||
const finishReturn = () => {
|
||
isReturningToTop = false
|
||
invalidateFreeScrollAnchor()
|
||
if (target <= 0) {
|
||
freeScrollOffset = 0
|
||
freeScrollLastApplied = 0
|
||
progress.value = 0
|
||
} else {
|
||
syncProgressFromY(target)
|
||
}
|
||
}
|
||
if (!dist) {
|
||
finishReturn()
|
||
return
|
||
}
|
||
invalidateFreeScrollAnchor()
|
||
isReturningToTop = true
|
||
if (duration <= 0) {
|
||
window.scrollTo(0, target)
|
||
finishReturn()
|
||
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
|
||
const y = start + dist * e
|
||
window.scrollTo(0, y)
|
||
syncProgressFromY(y)
|
||
if (p < 1) animId = requestAnimationFrame(tick)
|
||
else finishReturn()
|
||
}
|
||
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
|
||
&& !isReturningToTop
|
||
&& !isDrawerOpen.value
|
||
&& !showBlessingDrawer.value
|
||
&& !showGiftPanel.value
|
||
&& !previewVisible.value
|
||
&& !showMapOptions.value
|
||
&& !showOpenInBrowser.value
|
||
|
||
const syncProgressFromY = (y) => {
|
||
const max = pageScrollMax()
|
||
progress.value = max > 0 ? Math.min(1, Math.max(0, y / max)) : 0
|
||
}
|
||
|
||
/** 图挂载后刷新文档高度(画廊不停滚,仅校正 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
|
||
freeScrollLastApplied = 0
|
||
progress.value = 0
|
||
isReturningToTop = false
|
||
}
|
||
|
||
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
|
||
flushPendingEntranceFx()
|
||
scheduleAutoScrollStart()
|
||
}
|
||
|
||
/** Loading / 揭幕期间先积压,结束后再播 */
|
||
const shouldDeferEntranceFx = () => showBootLoading.value || showIntro.value
|
||
|
||
const flushPendingEntranceFx = () => {
|
||
if (pendingLikeBurst > 0) {
|
||
const n = pendingLikeBurst
|
||
pendingLikeBurst = 0
|
||
nextTick(() => likeBurstRef.value?.burst(n, { durationMs: 2800, maxVisual: 10 }))
|
||
}
|
||
if (pendingGiftReplay.value.length) {
|
||
const list = pendingGiftReplay.value.slice()
|
||
pendingGiftReplay.value = []
|
||
if (showGiftFx.value) {
|
||
nextTick(() => giftEffectRef.value?.playSequence(list, { max: 12 }))
|
||
}
|
||
}
|
||
}
|
||
|
||
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 (currentMusicPlayMode() === 'random') rememberIfCached(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 visitorIds = async () => {
|
||
const fingerprint = await getFingerprint()
|
||
return { fingerprint: fingerprint || '', client_id: getClientId() }
|
||
}
|
||
|
||
const handleSubmitRsvp = async () => {
|
||
if (!rsvpForm.name.trim()) return toast.error('请填写姓名')
|
||
try {
|
||
const ids = await visitorIds()
|
||
const res = await submitRsvp({ ...rsvpForm, ...ids })
|
||
if (res.code === 200) {
|
||
setGuestName(rsvpForm.name)
|
||
syncGuestNameIntoForms()
|
||
if (showDanmaku.value && 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 ids = await visitorIds()
|
||
const res = await submitDanmaku({
|
||
name: blessingForm.name.trim(),
|
||
content: blessingForm.content.trim(),
|
||
color: blessingForm.color || DEFAULT_DANMAKU_COLOR,
|
||
...ids,
|
||
})
|
||
if (res.code === 200) {
|
||
setGuestName(blessingForm.name)
|
||
syncGuestNameIntoForms()
|
||
if (showDanmaku.value && 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 applyGiftCounts = (counts, total) => {
|
||
const next = emptyGiftCounts()
|
||
if (counts && typeof counts === 'object') {
|
||
for (const k of Object.keys(next)) {
|
||
next[k] = Number(counts[k]) || 0
|
||
}
|
||
}
|
||
Object.assign(giftCounts, next)
|
||
giftTotal.value = Number(total) || Object.values(next).reduce((a, b) => a + b, 0)
|
||
}
|
||
|
||
let giftPanelCloseTimer = null
|
||
const GIFT_PANEL_IDLE_CLOSE_MS = 800
|
||
|
||
const clearGiftPanelCloseTimer = () => {
|
||
clearTimeout(giftPanelCloseTimer)
|
||
giftPanelCloseTimer = null
|
||
}
|
||
|
||
const scheduleGiftPanelClose = () => {
|
||
clearGiftPanelCloseTimer()
|
||
giftPanelCloseTimer = setTimeout(() => {
|
||
giftPanelCloseTimer = null
|
||
closeGiftPanel()
|
||
}, GIFT_PANEL_IDLE_CLOSE_MS)
|
||
}
|
||
|
||
const openGiftPanel = async () => {
|
||
clearGiftPanelCloseTimer()
|
||
showLikeBubbles.value = false
|
||
syncGuestNameIntoForms()
|
||
giftForm.name = getGuestName() || giftForm.name
|
||
showGiftPanel.value = true
|
||
await nextTick()
|
||
const el = giftPagesRef.value
|
||
if (el) {
|
||
const maxPage = Math.max(0, giftPages.length - 1)
|
||
const page = Math.min(Math.max(0, giftPageIndex.value), maxPage)
|
||
giftPageIndex.value = page
|
||
el.scrollLeft = page * (el.clientWidth || 0)
|
||
}
|
||
try {
|
||
const res = await getGiftStatus(getClientId())
|
||
applyGiftCounts(res.data?.counts, res.data?.total)
|
||
applyGiftQuota(res.data?.quota)
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const onGiftPlayStart = ({ giftType, name, count, phase }) => {
|
||
if (!showGiftFx.value) return
|
||
if (phase === 'mult') {
|
||
giftFeedRef.value?.revealCount(giftType, count)
|
||
return
|
||
}
|
||
giftFeedRef.value?.push(name, giftType, count)
|
||
}
|
||
|
||
const closeGiftPanel = () => {
|
||
clearGiftPanelCloseTimer()
|
||
showGiftPanel.value = false
|
||
}
|
||
|
||
const closeGiftNameModal = () => {
|
||
showGiftNameModal.value = false
|
||
pendingGiftType.value = ''
|
||
giftNameDraft.value = ''
|
||
}
|
||
|
||
const openGiftNameModal = (giftType) => {
|
||
pendingGiftType.value = giftType
|
||
giftNameDraft.value = giftForm.name.trim() || getGuestName() || ''
|
||
showGiftNameModal.value = true
|
||
nextTick(() => {
|
||
giftNameInputRef.value?.focus?.()
|
||
})
|
||
}
|
||
|
||
const sendGiftRequest = async (giftType, name) => {
|
||
giftBusy.value = true
|
||
try {
|
||
const fingerprint = await getFingerprint()
|
||
const res = await submitGift({
|
||
giftType,
|
||
name,
|
||
clientId: getClientId(),
|
||
fingerprint,
|
||
})
|
||
setGuestName(name)
|
||
giftForm.name = name
|
||
syncGuestNameIntoForms()
|
||
applyGiftCounts(res.data?.counts, res.data?.total)
|
||
applyGiftQuota(res.data?.quota)
|
||
if (showGiftFx.value) {
|
||
giftEffectRef.value?.play({ giftType, name, count: 1 })
|
||
}
|
||
toast.success('心意已送达')
|
||
scheduleGiftPanelClose()
|
||
} catch (e) {
|
||
toast.error(e.message || '送礼失败')
|
||
try {
|
||
const res = await getGiftStatus(getClientId())
|
||
applyGiftQuota(res.data?.quota)
|
||
} catch { /* ignore */ }
|
||
} finally {
|
||
giftBusy.value = false
|
||
}
|
||
}
|
||
|
||
const confirmGiftName = async () => {
|
||
const name = giftNameDraft.value.trim()
|
||
if (!name) return toast.error('请填写姓名')
|
||
const giftType = pendingGiftType.value
|
||
showGiftNameModal.value = false
|
||
pendingGiftType.value = ''
|
||
giftNameDraft.value = ''
|
||
if (!giftType) return
|
||
clearGiftPanelCloseTimer()
|
||
await sendGiftRequest(giftType, name)
|
||
}
|
||
|
||
const handleSendGift = async (giftType) => {
|
||
// 点了任意礼物就延后关闭;忙碌中也续上 800ms,方便连送
|
||
clearGiftPanelCloseTimer()
|
||
if (giftBusy.value) {
|
||
scheduleGiftPanelClose()
|
||
return
|
||
}
|
||
const cost = giftCostOf(giftType)
|
||
if (giftQuota.available < cost) {
|
||
const need = cost - giftQuota.available
|
||
scheduleGiftPanelClose()
|
||
return toast.error(`点赞不足,再点赞 ${need} 次可兑换`)
|
||
}
|
||
const name = giftForm.name.trim() || getGuestName()
|
||
if (!name) {
|
||
openGiftNameModal(giftType)
|
||
return
|
||
}
|
||
await sendGiftRequest(giftType, name)
|
||
}
|
||
|
||
const loadLikeStatus = async () => {
|
||
try {
|
||
const res = await getLikeStatus(getClientId())
|
||
const count = Number(res.data?.count) || 0
|
||
likeCount.value = count
|
||
if (count <= 0) return
|
||
// Loading / 揭幕中则等结束后再播,避免帘幕后白播
|
||
if (shouldDeferEntranceFx()) pendingLikeBurst = count
|
||
else nextTick(() => likeBurstRef.value?.burst(count, { durationMs: 2800, maxVisual: 10 }))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const loadGiftStatus = async () => {
|
||
try {
|
||
const res = await getGiftStatus(getClientId())
|
||
applyGiftCounts(res.data?.counts, res.data?.total)
|
||
applyGiftQuota(res.data?.quota)
|
||
const recent = Array.isArray(res.data?.recent) ? res.data.recent : []
|
||
const aggregated = aggregateGiftRecent(recent)
|
||
if (!aggregated.length) return
|
||
if (shouldDeferEntranceFx()) pendingGiftReplay.value = aggregated
|
||
else if (showGiftFx.value) nextTick(() => giftEffectRef.value?.playSequence(aggregated, { max: 12 }))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const handleLike = async (count = 1) => {
|
||
if (likeBusy.value) return
|
||
const n = Math.max(1, Math.min(200, Number(count) || 1))
|
||
likeBusy.value = true
|
||
try {
|
||
const fingerprint = await getFingerprint()
|
||
const res = await submitLike(getClientId(), n, fingerprint)
|
||
likeCount.value = Number(res.data?.count) || likeCount.value + n
|
||
likePulse.value = true
|
||
clearTimeout(likePulseTimer)
|
||
likePulseTimer = setTimeout(() => { likePulse.value = false }, 600)
|
||
likeBurstRef.value?.burst(n, n > 1 ? { durationMs: 2400, maxVisual: 9 } : undefined)
|
||
if (res.data?.quota) applyGiftQuota(res.data.quota)
|
||
else {
|
||
giftQuota.my_likes += n
|
||
giftQuota.available = Math.max(0, giftQuota.my_likes - giftQuota.spent)
|
||
}
|
||
} catch (e) {
|
||
toast.error(e.message || '点赞失败')
|
||
} finally {
|
||
likeBusy.value = false
|
||
}
|
||
}
|
||
|
||
// ---------- 后台实时预览模式(/?preview=1,被 AdminEditor iframe 嵌入) ----------
|
||
const isPreviewMode = typeof window !== 'undefined'
|
||
&& new URLSearchParams(window.location.search).has('preview')
|
||
// 预览挂在后台手机壳里:隐藏所有滚动条,模拟真机观感(样式见 style.css html.preview-mode)
|
||
if (isPreviewMode) {
|
||
try { document.documentElement.classList.add('preview-mode') } catch { /* ignore */ }
|
||
}
|
||
/** 后台推来的配置覆盖:正式配置加载完成后需再套用一次,避免被覆盖 */
|
||
let previewOverrides = null
|
||
|
||
function applyPreviewOverrides(partial) {
|
||
if (!partial || typeof partial !== 'object') return
|
||
const merged = { ...partial }
|
||
if (merged.musicUrl && !(merged.musicList && merged.musicList.length)) {
|
||
merged.musicList = [{ url: merged.musicUrl, name: '背景音乐' }]
|
||
}
|
||
data.value = { ...data.value, ...merged }
|
||
syncPageScrollMode()
|
||
nextTick(() => invalidateScrollMax())
|
||
}
|
||
|
||
function onPreviewMessage(e) {
|
||
if (e.origin !== window.location.origin) return
|
||
const msg = e.data
|
||
if (!msg || msg.type !== 'wedding-preview-config' || !msg.data) return
|
||
previewOverrides = msg.data
|
||
applyPreviewOverrides(previewOverrides)
|
||
}
|
||
|
||
// ---------- 章节指示点 ----------
|
||
const sectionDotCount = ref(0)
|
||
const currentSectionIdx = ref(0)
|
||
let sectionObserver = null
|
||
let dotSectionEls = []
|
||
|
||
function setupSectionDots() {
|
||
sectionObserver?.disconnect()
|
||
sectionObserver = null
|
||
dotSectionEls = sectionEls()
|
||
sectionDotCount.value = dotSectionEls.length
|
||
if (dotSectionEls.length < 2 || typeof IntersectionObserver === 'undefined') return
|
||
// 中线带检测:视口中部落在哪个章节内即为当前章节(对超高章节同样可靠)
|
||
sectionObserver = new IntersectionObserver((entries) => {
|
||
for (const en of entries) {
|
||
if (!en.isIntersecting) continue
|
||
const i = dotSectionEls.indexOf(en.target)
|
||
if (i >= 0) currentSectionIdx.value = i
|
||
}
|
||
}, { rootMargin: '-45% 0px -45% 0px', threshold: 0 })
|
||
dotSectionEls.forEach((el) => sectionObserver.observe(el))
|
||
}
|
||
|
||
function goToSection(i) {
|
||
const el = dotSectionEls[i]
|
||
if (!el) return
|
||
// 与手动触摸一致:先交还滚动控制权(退出 transform/自动滚),再平滑跳转
|
||
handleUserInteraction()
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
|
||
window.scrollTo(0, 0)
|
||
const bootStartedAt = Date.now()
|
||
const BOOT_MIN_MS = 1100
|
||
|
||
if (isPreviewMode) {
|
||
window.addEventListener('message', onPreviewMessage)
|
||
if (window.parent && window.parent !== window) {
|
||
try {
|
||
window.parent.postMessage({ type: 'wedding-preview-ready' }, window.location.origin)
|
||
} catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
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 (loaded.heroImgResized) loaded.heroImgResized = optimizeImageUrl(loaded.heroImgResized)
|
||
if (loaded.endImgResized) loaded.endImgResized = optimizeImageUrl(loaded.endImgResized)
|
||
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) { /* 使用默认配置 */ }
|
||
|
||
// 预览模式:正式配置加载后重放后台推送的覆盖,保证编辑内容优先
|
||
if (isPreviewMode && previewOverrides) applyPreviewOverrides(previewOverrides)
|
||
|
||
syncPageScrollMode()
|
||
albumLoaded.value = false
|
||
// 安卓专项:不在首屏预挂载整本相册,避免 <a-image> 全量创建导致主线程卡顿
|
||
// 改为用户首次点开预览时再加载(loadAlbumPreviewImages 内部有缓存保护)
|
||
albumPreviewImages.value = []
|
||
loadLikeStatus()
|
||
loadGiftStatus()
|
||
if (!isPreviewMode) sendVisitBeacon()
|
||
|
||
// Loading 期间预热封面,避免揭幕后闪空图
|
||
await preloadImage(data.value.heroImgResized || data.value.heroImg)
|
||
|
||
const remain = BOOT_MIN_MS - (Date.now() - bootStartedAt)
|
||
if (remain > 0) await new Promise((r) => setTimeout(r, remain))
|
||
|
||
// 先挂上揭幕帘,再淡出 Loading,避免中间一帧露出正文
|
||
if (data.value.introEnabled) showIntro.value = true
|
||
await nextTick()
|
||
await finishBootLoading()
|
||
// 未开启揭幕时,Loading 结束后再播积压特效
|
||
if (!data.value.introEnabled) flushPendingEntranceFx()
|
||
|
||
// 兼容旧配置:补全 musicPlayMode
|
||
data.value.musicPlayMode = resolveMusicPlayMode(data.value)
|
||
data.value.musicRandomEnabled = data.value.musicPlayMode === 'random'
|
||
|
||
const pickedUrl = pickMusicUrl(
|
||
data.value.musicList,
|
||
data.value.activeMusicUrl,
|
||
musicCacheOptions(),
|
||
)
|
||
syncMusicPlaybackMode()
|
||
audio.onEnded(() => {
|
||
const mode = currentMusicPlayMode()
|
||
if (mode === 'single') return
|
||
if (mode === 'random') {
|
||
playRandomTrack(audio.activeUrl.value)
|
||
return
|
||
}
|
||
if (mode === 'reverse') {
|
||
audio.prev()
|
||
return
|
||
}
|
||
audio.next()
|
||
})
|
||
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()
|
||
setupSectionDots()
|
||
})
|
||
|
||
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.removeEventListener('message', onPreviewMessage)
|
||
window.visualViewport?.removeEventListener('resize', invalidateScrollMax)
|
||
scrollHeightObserver?.disconnect()
|
||
scrollHeightObserver = null
|
||
sectionObserver?.disconnect()
|
||
sectionObserver = null
|
||
cancelAnimationFrame(scrollRaf)
|
||
scrollRaf = 0
|
||
cancelAnimationFrame(rafId)
|
||
cancelAnimationFrame(animId)
|
||
clearInterval(snapTimer)
|
||
clearInterval(freeTimer)
|
||
clearTimeout(pauseTimer)
|
||
clearTimeout(introTimer)
|
||
clearTimeout(scrollStartTimer)
|
||
clearTimeout(likePressTimer)
|
||
clearTimeout(likeBubblesHideTimer)
|
||
clearTimeout(likePulseTimer)
|
||
clearTimeout(danmakuPressTimer)
|
||
clearTimeout(danmakuAreaMenuHideTimer)
|
||
clearGiftPanelCloseTimer()
|
||
clearBottomReturn()
|
||
clearBlessingAutoClose()
|
||
})
|
||
|
||
watch(() => data.value.scrollMode, syncPageScrollMode)
|
||
watch(
|
||
() => data.value.photoPages,
|
||
async () => {
|
||
await nextTick()
|
||
refreshScrollMax()
|
||
setupSectionDots()
|
||
},
|
||
{ deep: true }
|
||
)
|
||
watch(freeScrollSignature, restartAutoScroll)
|
||
watch(
|
||
() => [isDrawerOpen.value, showBlessingDrawer.value, showGiftPanel.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
||
() => {
|
||
if (isDrawerOpen.value || showBlessingDrawer.value || showGiftPanel.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(var(--c-primary-rgb), 0.3); color: var(--c-primary); background: color-mix(in srgb, var(--c-card) 62%, transparent);
|
||
}
|
||
.danmaku-area-menu {
|
||
position: absolute;
|
||
top: 0;
|
||
right: calc(100% + 10px);
|
||
display: flex;
|
||
gap: 6px;
|
||
padding: 8px;
|
||
border-radius: 14px;
|
||
background: rgba(255, 252, 247, 0.96);
|
||
border: 1px solid rgba(var(--c-primary-rgb), 0.22);
|
||
box-shadow: 0 10px 28px rgba(88, 68, 42, 0.14);
|
||
backdrop-filter: blur(10px);
|
||
z-index: 5;
|
||
}
|
||
.danmaku-area-opt {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
padding: 4px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s;
|
||
}
|
||
.danmaku-area-opt:hover {
|
||
background: rgba(var(--c-primary-rgb), 0.1);
|
||
}
|
||
.danmaku-area-opt.active {
|
||
background: rgba(var(--c-primary-rgb), 0.16);
|
||
border-color: rgba(var(--c-primary-rgb), 0.35);
|
||
}
|
||
.danmaku-area-phone {
|
||
position: relative;
|
||
width: 22px;
|
||
height: 36px;
|
||
border-radius: 5px;
|
||
border: 1.5px solid #c4b59e;
|
||
background: #f7f2ea;
|
||
overflow: hidden;
|
||
display: block;
|
||
}
|
||
.danmaku-area-zone {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
background: rgba(var(--c-primary-rgb), 0.72);
|
||
}
|
||
.danmaku-area-zone[data-area='top30'] { top: 0; height: 30%; }
|
||
.danmaku-area-zone[data-area='top'] { top: 0; height: 50%; }
|
||
.danmaku-area-zone[data-area='bottom'] { bottom: 0; height: 50%; }
|
||
.danmaku-area-zone[data-area='full'] { inset: 0; }
|
||
.danmaku-area-opt.active .danmaku-area-zone {
|
||
background: var(--c-primary);
|
||
}
|
||
.danmaku-area-opt.active .danmaku-area-phone {
|
||
border-color: var(--c-primary);
|
||
}
|
||
.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(260px + 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;
|
||
overflow: visible;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
-webkit-touch-callout: none;
|
||
touch-action: manipulation;
|
||
}
|
||
.like-bubbles {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 0;
|
||
height: 0;
|
||
z-index: 8;
|
||
pointer-events: none;
|
||
}
|
||
.like-bubble-btn {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
pointer-events: auto;
|
||
margin: 0;
|
||
min-width: 52px;
|
||
border-color: rgba(244, 114, 182, 0.4);
|
||
background: rgba(255, 241, 247, 0.72);
|
||
color: #f472b6;
|
||
box-shadow: 0 6px 16px rgba(92, 74, 53, 0.12);
|
||
animation: like-bubble-in 0.28s cubic-bezier(0.22, 1.2, 0.36, 1) both;
|
||
animation-delay: var(--delay, 0s);
|
||
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px)));
|
||
}
|
||
.like-bubble-btn .like-icon { color: #f472b6; }
|
||
.like-bubble-btn .like-count { color: #e11d74; font-weight: 600; }
|
||
.like-bubble-btn:active {
|
||
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px))) scale(0.94);
|
||
}
|
||
.like-bubble-btn:disabled { opacity: 0.55; }
|
||
@keyframes like-bubble-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: translate(-50%, -50%) scale(0.55);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px))) scale(1);
|
||
}
|
||
}
|
||
.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(var(--c-primary-rgb), 0.28);
|
||
background: rgba(255, 253, 249, 0.42);
|
||
color: var(--c-primary);
|
||
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;
|
||
/* 防长按选字 / iOS 连点双击放大 */
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
-webkit-touch-callout: none;
|
||
touch-action: manipulation;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.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: var(--c-primary); line-height: 1; }
|
||
.like-fab.liked .like-icon { color: #f472b6; }
|
||
.like-count {
|
||
font-size: 11px; letter-spacing: 0.02em; color: var(--c-primary);
|
||
line-height: 1; text-align: center; max-width: 100%;
|
||
}
|
||
.like-fab.liked .like-count { color: #f472b6; font-weight: 600; }
|
||
.gift-fab {
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
padding: 8px 12px;
|
||
min-width: 52px;
|
||
}
|
||
.gift-fab.busy { opacity: 0.7; pointer-events: none; }
|
||
.gift-icon { font-size: 16px; color: var(--c-primary); line-height: 1; }
|
||
.gift-panel {
|
||
height: calc(268px + env(safe-area-inset-bottom, 0px));
|
||
max-height: calc(268px + env(safe-area-inset-bottom, 0px));
|
||
overflow: hidden;
|
||
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||
background: rgba(55, 55, 60, 0.94);
|
||
color: #e8e6e2;
|
||
}
|
||
.gift-panel-handle {
|
||
width: 32px;
|
||
height: 3px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.22);
|
||
margin: 0 auto 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
.gift-quota {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
padding: 6px 8px;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: rgba(232, 230, 226, 0.78);
|
||
font-size: 10px;
|
||
line-height: 1.4;
|
||
letter-spacing: 0.04em;
|
||
flex-shrink: 0;
|
||
}
|
||
.gift-quota b { color: #f0e6c8; font-weight: 600; }
|
||
.gift-quota-sub {
|
||
font-size: 9px;
|
||
color: rgba(232, 230, 226, 0.48);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.gift-name-input {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 0.5px solid rgba(255, 255, 255, 0.14);
|
||
color: #f5f3ef;
|
||
flex-shrink: 0;
|
||
}
|
||
.gift-name-input::placeholder {
|
||
color: rgba(232, 230, 226, 0.4);
|
||
}
|
||
.gift-name-input:focus {
|
||
border-color: rgba(240, 230, 200, 0.45);
|
||
}
|
||
.gift-pages {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scroll-snap-type: x mandatory;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
}
|
||
.gift-pages::-webkit-scrollbar { display: none; }
|
||
.gift-page {
|
||
flex: 0 0 100%;
|
||
width: 100%;
|
||
scroll-snap-align: start;
|
||
scroll-snap-stop: always;
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 4px;
|
||
align-content: start;
|
||
box-sizing: border-box;
|
||
padding-right: 1px;
|
||
}
|
||
.gift-page-dots {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding-top: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
.gift-page-dot {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.22);
|
||
transition: background 0.2s ease, transform 0.2s ease;
|
||
}
|
||
.gift-page-dot.active {
|
||
background: rgba(240, 230, 200, 0.85);
|
||
transform: scale(1.15);
|
||
}
|
||
.gift-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 1px;
|
||
min-height: 88px;
|
||
padding: 6px 2px 4px;
|
||
border-radius: 10px;
|
||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: #e8e6e2;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
|
||
overflow: hidden;
|
||
}
|
||
.gift-item:active { transform: scale(0.96); }
|
||
.gift-item:disabled { opacity: 0.45; pointer-events: none; }
|
||
.gift-item-preview {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 36px;
|
||
line-height: 1;
|
||
font-size: clamp(15px, 4.6vw, 21px);
|
||
letter-spacing: 0.02em;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
background-clip: text;
|
||
-webkit-background-clip: text;
|
||
color: transparent;
|
||
-webkit-text-fill-color: transparent;
|
||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
|
||
}
|
||
.gift-item-caption {
|
||
font-size: 8px;
|
||
line-height: 1.15;
|
||
color: rgba(232, 230, 226, 0.5);
|
||
letter-spacing: 0.03em;
|
||
text-align: center;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.gift-item-cost {
|
||
margin-top: 1px;
|
||
font-size: 9px;
|
||
line-height: 1;
|
||
color: rgba(240, 230, 200, 0.72);
|
||
letter-spacing: 0.02em;
|
||
white-space: nowrap;
|
||
}
|
||
.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(var(--c-primary-rgb), 0.26);
|
||
background: rgba(255, 253, 249, 0.38);
|
||
color: var(--c-primary);
|
||
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, var(--c-primary-deep) 70%, var(--c-ink-soft));
|
||
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, var(--c-primary));
|
||
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(var(--c-primary-rgb), 0.08),
|
||
rgba(var(--c-primary-rgb), 0.18),
|
||
rgba(var(--c-primary-rgb), 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%, var(--c-primary) 55%, var(--c-primary-deep) 100%);
|
||
box-shadow: 0 0 8px rgba(var(--c-primary-rgb), 0.45);
|
||
transform: scaleX(var(--scroll-p, 0));
|
||
transform-origin: left center;
|
||
will-change: transform;
|
||
}
|
||
.scroll-progress-xi {
|
||
/* width:100% 使 transform 的 % 相对进度条宽度,而非字本身 */
|
||
position: absolute;
|
||
top: -1px;
|
||
left: 0;
|
||
width: 100%;
|
||
font-size: 11px;
|
||
line-height: 1;
|
||
color: #c62828;
|
||
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) * (100% - 1.1em)), 0, 0);
|
||
will-change: transform;
|
||
font-weight: 700;
|
||
letter-spacing: 0;
|
||
pointer-events: none;
|
||
}
|
||
.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: var(--c-ink-soft);
|
||
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: var(--c-muted);
|
||
font-size: 12px;
|
||
background: rgba(var(--c-primary-rgb), 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(var(--c-primary-rgb), 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: var(--c-ink-soft);
|
||
font-weight: 600;
|
||
background: color-mix(in srgb, var(--c-card) 40%, transparent);
|
||
transform: scale(1.02);
|
||
}
|
||
.player-lrc-empty {
|
||
margin: 0;
|
||
padding: 18px 8px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: var(--c-muted);
|
||
}
|
||
.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: var(--c-primary);
|
||
font-size: 12px;
|
||
background: rgba(var(--c-primary-rgb), 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, var(--c-primary));
|
||
color: #fff;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
box-shadow: 0 6px 14px rgba(var(--c-primary-rgb), 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: var(--c-primary);
|
||
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: var(--c-primary);
|
||
font-size: 12px;
|
||
flex: 0 0 auto;
|
||
background: rgba(var(--c-primary-rgb), 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: var(--c-muted);
|
||
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: var(--c-ink-soft);
|
||
}
|
||
.player-track-item.active {
|
||
background: rgba(var(--c-primary-rgb), 0.14);
|
||
color: var(--c-primary);
|
||
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%,
|
||
var(--c-primary) var(--vol, 50%),
|
||
rgba(var(--c-primary-rgb), 0.18) var(--vol, 50%),
|
||
rgba(var(--c-primary-rgb), 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(var(--c-primary-rgb), 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(var(--c-primary-rgb), 0.35);
|
||
cursor: pointer;
|
||
}
|
||
.player-seek-wrap { width: 100%; }
|
||
.player-time {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 10px;
|
||
color: var(--c-muted);
|
||
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%,
|
||
var(--c-primary) var(--seek, 0%),
|
||
rgba(var(--c-primary-rgb), 0.18) var(--seek, 0%),
|
||
rgba(var(--c-primary-rgb), 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(var(--c-primary-rgb), 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(var(--c-primary-rgb), 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: var(--c-ink);
|
||
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb),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: var(--c-primary);
|
||
background: transparent; border: 0.5px solid rgba(var(--c-primary-rgb),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(var(--c-primary-rgb),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(var(--c-primary-rgb),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: var(--c-muted); padding: 2px 6px 6px;
|
||
}
|
||
.ai-style-item {
|
||
text-align: left; font-size: 12px; color: var(--c-ink-soft);
|
||
padding: 8px 10px; border-radius: 10px; transition: background .15s;
|
||
}
|
||
.ai-style-item:hover { background: rgba(var(--c-primary-rgb),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(var(--c-primary-rgb),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: var(--c-primary); 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: var(--c-muted); 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(var(--c-primary-rgb),0.54) 12%, rgba(var(--c-primary-rgb),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(var(--c-primary-rgb),0.28);
|
||
border-bottom: 1px solid rgba(var(--c-primary-rgb),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: var(--c-bg); border: 1px solid rgba(var(--c-primary-rgb),0.52);
|
||
box-shadow: 0 0 0 5px rgba(var(--c-primary-rgb),0.09), 0 6px 16px rgba(112,78,40,0.12);
|
||
}
|
||
.schedule-node i {
|
||
width: 6px; height: 6px; border-radius: 9999px; display: block;
|
||
background: var(--c-primary);
|
||
}
|
||
.schedule-copy {
|
||
min-width: 0; padding: 13px 0 13px 2px; text-align: left;
|
||
border-bottom: 1px solid rgba(var(--c-primary-rgb),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: var(--c-muted); 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(var(--c-primary-rgb),0.12), transparent 28%);
|
||
}
|
||
.calendar-card {
|
||
width: min(100%, 352px); margin: 0 auto; padding: 30px 24px 28px;
|
||
color: var(--c-ink-soft); 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(var(--c-primary-rgb),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(var(--c-primary-rgb),0.16); pointer-events: none;
|
||
}
|
||
.calendar-kicker {
|
||
color: var(--c-primary); 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(var(--c-primary-rgb),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: var(--c-primary); 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(var(--c-primary-rgb),0.45);
|
||
}
|
||
|
||
/* ---------- 揭幕前 Loading(象牙金主题) ---------- */
|
||
.boot-loading {
|
||
--boot-gold: var(--c-primary);
|
||
--boot-gold-soft: var(--c-line);
|
||
--boot-ink: var(--c-ink-soft);
|
||
--boot-cream: var(--c-bg);
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 200;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
background:
|
||
radial-gradient(ellipse at 50% 42%, rgba(255, 252, 246, 0.96), transparent 55%),
|
||
radial-gradient(ellipse at 18% 78%, rgba(var(--c-line-rgb), 0.14), transparent 42%),
|
||
radial-gradient(ellipse at 82% 18%, rgba(var(--c-primary-rgb), 0.12), transparent 40%),
|
||
linear-gradient(165deg, var(--c-paper) 0%, #f7efe4 46%, #eddcc6 100%);
|
||
transition: opacity 0.48s ease, visibility 0.48s ease, transform 0.48s ease;
|
||
}
|
||
.boot-loading.is-leaving {
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
transform: scale(1.02);
|
||
}
|
||
.boot-decor {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
}
|
||
.boot-glow {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(28px);
|
||
opacity: 0.55;
|
||
animation: boot-glow-drift 5.5s ease-in-out infinite;
|
||
}
|
||
.boot-glow-1 {
|
||
width: 180px; height: 180px; left: 8%; top: 18%;
|
||
background: rgba(var(--c-line-rgb), 0.28);
|
||
}
|
||
.boot-glow-2 {
|
||
width: 220px; height: 220px; right: 4%; bottom: 16%;
|
||
background: rgba(var(--c-primary-rgb), 0.22);
|
||
animation-delay: -2s;
|
||
}
|
||
.boot-glow-3 {
|
||
width: 140px; height: 140px; left: 42%; top: 8%;
|
||
background: rgba(255, 248, 238, 0.7);
|
||
animation-delay: -3.4s;
|
||
}
|
||
.boot-petal {
|
||
position: absolute;
|
||
color: var(--boot-gold);
|
||
opacity: 0.28;
|
||
font-size: 18px;
|
||
animation: boot-petal-float 4.8s ease-in-out infinite;
|
||
}
|
||
.boot-petal-1 { left: 14%; top: 28%; font-size: 22px; animation-delay: 0s; }
|
||
.boot-petal-2 { right: 16%; top: 34%; font-size: 16px; animation-delay: -1.2s; }
|
||
.boot-petal-3 { left: 20%; bottom: 22%; font-size: 14px; opacity: 0.22; animation-delay: -2.1s; }
|
||
.boot-petal-4 { right: 18%; bottom: 26%; font-size: 13px; opacity: 0.2; animation-delay: -3s; }
|
||
.boot-spark {
|
||
position: absolute;
|
||
width: 4px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: var(--boot-gold-soft);
|
||
box-shadow: 0 0 8px rgba(var(--c-line-rgb), 0.55);
|
||
animation: boot-twinkle 2.4s ease-in-out infinite;
|
||
}
|
||
.boot-spark-1 { left: 22%; top: 18%; animation-delay: 0s; }
|
||
.boot-spark-2 { right: 24%; top: 22%; animation-delay: 0.4s; }
|
||
.boot-spark-3 { left: 28%; bottom: 18%; animation-delay: 0.9s; }
|
||
.boot-spark-4 { right: 26%; bottom: 20%; animation-delay: 1.3s; }
|
||
.boot-spark-5 { left: 50%; top: 14%; margin-left: -2px; animation-delay: 1.7s; }
|
||
.boot-frame {
|
||
position: absolute;
|
||
width: 52px;
|
||
height: 52px;
|
||
border-color: rgba(var(--c-primary-rgb), 0.38);
|
||
border-style: solid;
|
||
border-width: 0;
|
||
opacity: 0;
|
||
animation: boot-frame-in 0.7s ease 0.15s forwards;
|
||
}
|
||
.boot-frame-tl { top: 22px; left: 22px; border-top-width: 1px; border-left-width: 1px; }
|
||
.boot-frame-tr { top: 22px; right: 22px; border-top-width: 1px; border-right-width: 1px; }
|
||
.boot-frame-bl { bottom: 22px; left: 22px; border-bottom-width: 1px; border-left-width: 1px; }
|
||
.boot-frame-br { bottom: 22px; right: 22px; border-bottom-width: 1px; border-right-width: 1px; }
|
||
.boot-stage {
|
||
position: relative;
|
||
z-index: 2;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 0 28px;
|
||
text-align: center;
|
||
animation: boot-stage-in 0.75s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||
}
|
||
.boot-kicker {
|
||
display: block;
|
||
font-size: 11px;
|
||
letter-spacing: 0.48em;
|
||
padding-left: 0.48em;
|
||
color: var(--boot-gold);
|
||
font-weight: 700;
|
||
margin-bottom: 18px;
|
||
opacity: 0.92;
|
||
}
|
||
.boot-rule {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 22px;
|
||
color: var(--boot-gold-soft);
|
||
font-size: 10px;
|
||
opacity: 0.75;
|
||
}
|
||
.boot-rule span {
|
||
display: block;
|
||
width: 42px;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(var(--c-primary-rgb), 0.55));
|
||
}
|
||
.boot-rule span:last-child {
|
||
background: linear-gradient(90deg, rgba(var(--c-primary-rgb), 0.55), transparent);
|
||
}
|
||
.boot-rule-flip { margin-top: 22px; margin-bottom: 0; }
|
||
.boot-seal-wrap {
|
||
position: relative;
|
||
width: 108px;
|
||
height: 108px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.boot-seal-ring {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
border-style: solid;
|
||
border-color: transparent;
|
||
box-sizing: border-box;
|
||
}
|
||
.boot-seal-ring-a {
|
||
inset: -10px;
|
||
border-width: 1.5px;
|
||
border-top-color: rgba(var(--c-primary-rgb), 0.72);
|
||
border-right-color: rgba(var(--c-line-rgb), 0.18);
|
||
animation: boot-ring-spin 1.8s linear infinite;
|
||
}
|
||
.boot-seal-ring-b {
|
||
inset: -2px;
|
||
border-width: 1px;
|
||
border-bottom-color: rgba(var(--c-line-rgb), 0.55);
|
||
border-left-color: rgba(var(--c-primary-rgb), 0.12);
|
||
animation: boot-ring-spin-rev 2.6s linear infinite;
|
||
}
|
||
.boot-seal {
|
||
width: 88px;
|
||
height: 88px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 40px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
color: var(--boot-gold);
|
||
background:
|
||
radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.65), transparent 46%),
|
||
linear-gradient(160deg, #fffaf3, #f3e6d6);
|
||
border: 1.5px solid rgba(var(--c-primary-rgb), 0.55);
|
||
box-shadow:
|
||
inset 0 0 0 5px rgba(var(--c-primary-rgb), 0.08),
|
||
0 12px 32px rgba(120, 90, 55, 0.14);
|
||
animation:
|
||
boot-seal-in 0.85s cubic-bezier(0.22, 1, 0.36, 1) both,
|
||
boot-seal-breathe 2.2s ease-in-out 0.85s infinite;
|
||
}
|
||
.boot-names {
|
||
margin: 20px 0 0;
|
||
font-size: 18px;
|
||
letter-spacing: 0.18em;
|
||
color: var(--boot-ink);
|
||
font-weight: 700;
|
||
}
|
||
.boot-names span {
|
||
display: inline-block;
|
||
margin: 0 8px;
|
||
font-style: italic;
|
||
color: var(--boot-gold-soft);
|
||
font-weight: 600;
|
||
}
|
||
.boot-hint {
|
||
margin: 12px 0 0;
|
||
font-size: 12px;
|
||
letter-spacing: 0.38em;
|
||
padding-left: 0.38em;
|
||
color: rgba(128, 106, 77, 0.78);
|
||
}
|
||
.boot-dots {
|
||
display: flex;
|
||
gap: 7px;
|
||
margin-top: 18px;
|
||
}
|
||
.boot-dots i {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
background: var(--boot-gold);
|
||
opacity: 0.28;
|
||
animation: boot-dot-pulse 1.15s ease-in-out infinite;
|
||
}
|
||
.boot-dots i:nth-child(2) { animation-delay: 0.18s; }
|
||
.boot-dots i:nth-child(3) { animation-delay: 0.36s; }
|
||
|
||
@keyframes boot-stage-in {
|
||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
@keyframes boot-frame-in {
|
||
from { opacity: 0; transform: scale(0.92); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
@keyframes boot-seal-in {
|
||
0% { opacity: 0; transform: scale(0.55) rotate(-8deg); }
|
||
65% { opacity: 1; transform: scale(1.06) rotate(1.5deg); }
|
||
100% { opacity: 1; transform: scale(1) rotate(0); }
|
||
}
|
||
@keyframes boot-seal-breathe {
|
||
0%, 100% { transform: scale(1); box-shadow: inset 0 0 0 5px rgba(var(--c-primary-rgb), 0.08), 0 12px 32px rgba(120, 90, 55, 0.14); }
|
||
50% { transform: scale(1.035); box-shadow: inset 0 0 0 5px rgba(var(--c-primary-rgb), 0.12), 0 14px 36px rgba(120, 90, 55, 0.18); }
|
||
}
|
||
@keyframes boot-ring-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
@keyframes boot-ring-spin-rev {
|
||
to { transform: rotate(-360deg); }
|
||
}
|
||
@keyframes boot-glow-drift {
|
||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||
50% { transform: translate3d(10px, -12px, 0) scale(1.08); }
|
||
}
|
||
@keyframes boot-petal-float {
|
||
0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0.22; }
|
||
50% { transform: translateY(-10px) rotate(8deg); opacity: 0.4; }
|
||
}
|
||
@keyframes boot-twinkle {
|
||
0%, 100% { opacity: 0.15; transform: scale(0.7); }
|
||
50% { opacity: 0.9; transform: scale(1.25); }
|
||
}
|
||
@keyframes boot-dot-pulse {
|
||
0%, 100% { opacity: 0.22; transform: translateY(0); }
|
||
50% { opacity: 0.95; transform: translateY(-3px); }
|
||
}
|
||
|
||
/* ---------- 入场揭幕(雅致象牙金) ---------- */
|
||
.curtain-root { position: fixed; inset: 0; z-index: 100; overflow: hidden; cursor: pointer; font-family: var(--font-sans); background: var(--c-bg); 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, var(--c-bg) 0%, var(--c-bg-deep) 100%);
|
||
box-shadow: inset 0 0 58px rgba(var(--c-line-rgb),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(var(--c-line-rgb),0.5), transparent); }
|
||
.curtain-door::after {
|
||
content: ''; position: absolute; inset: 20px 18px;
|
||
border-top: 1px solid rgba(var(--c-primary-rgb),0.14);
|
||
border-bottom: 1px solid rgba(var(--c-primary-rgb),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;
|
||
}
|
||
|
||
/* —— 信封启封:蜡封弹开 → 翻盖掀起 → 请柬向上抽出 —— */
|
||
.envelope-flap {
|
||
display: none;
|
||
position: absolute; left: -1px; right: -1px; top: -1px; height: 150px;
|
||
z-index: 3;
|
||
clip-path: polygon(0 0, 100% 0, 100% 24%, 50% 100%, 0 24%);
|
||
background:
|
||
linear-gradient(180deg, rgba(255, 255, 255, 0.5), transparent 42%),
|
||
linear-gradient(180deg, #f9f1e3, #efdfc8 62%, #e6d2b4);
|
||
border-bottom: 1px solid rgba(var(--c-primary-rgb), 0.35);
|
||
box-shadow: inset 0 -3px 10px rgba(var(--c-primary-rgb), 0.2);
|
||
transform-origin: 50% 0;
|
||
transform-style: preserve-3d;
|
||
pointer-events: none;
|
||
}
|
||
.curtain-root.intro-exit-envelope .envelope-flap { display: block; }
|
||
.envelope-seal {
|
||
position: absolute; left: 50%; bottom: 8px; transform: translateX(-50%);
|
||
width: 46px; height: 46px; border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 20px; line-height: 1; color: rgba(255, 246, 238, 0.94);
|
||
background: radial-gradient(circle at 35% 30%, #d98a94, #b8505e 62%, #93394a);
|
||
box-shadow: 0 3px 10px rgba(110, 38, 50, 0.4), inset 0 0 0 3px rgba(255, 255, 255, 0.16), inset 0 -2px 6px rgba(70, 20, 28, 0.35);
|
||
}
|
||
.curtain-root.is-opening.intro-exit-envelope .curtain-emblem {
|
||
opacity: 1; transform: none; perspective: 1300px;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-envelope .envelope-seal {
|
||
animation: envelopeSealPop 380ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-envelope .envelope-flap {
|
||
animation: envelopeFlapOpen 860ms cubic-bezier(0.6, 0, 0.3, 1) 240ms forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-envelope .invite-cover {
|
||
will-change: transform, opacity;
|
||
animation: envelopeCardOut 1080ms cubic-bezier(0.55, 0, 0.25, 1) 640ms forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-envelope .emblem-inner {
|
||
animation: inviteContentExit 480ms cubic-bezier(0.4, 0, 1, 1) 780ms forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-envelope .curtain-door-left,
|
||
.curtain-root.is-opening.intro-exit-envelope .curtain-door-right { transform: none; }
|
||
.curtain-root.is-opening.intro-exit-envelope .curtain-bg,
|
||
.curtain-root.is-opening.intro-exit-envelope .curtain-veil,
|
||
.curtain-root.is-opening.intro-exit-envelope .curtain-door {
|
||
animation: bookBackdropFade 460ms ease-out 900ms forwards;
|
||
}
|
||
|
||
/* —— 贺卡掀页:整卡沿顶边 3D 上翻,带纸张柔皱与纸背 —— */
|
||
.paper-back,
|
||
.paper-fold { display: none; }
|
||
/* 纸背面:翻过 90° 后露出(米白纸背 + 淡淡透印),backface 由统一规则控制 */
|
||
.curtain-root.intro-exit-paper .paper-back {
|
||
display: block;
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 2;
|
||
transform: rotateX(180deg) translateZ(0.6px);
|
||
background:
|
||
repeating-linear-gradient(0deg, rgba(120, 88, 55, 0.022) 0 1px, transparent 1px 7px),
|
||
linear-gradient(160deg, #f6efe2, #efe5d2 55%, #e9dcc4);
|
||
border: 1px solid rgba(var(--c-primary-rgb), 0.3);
|
||
pointer-events: none;
|
||
}
|
||
.curtain-root.intro-exit-paper .paper-back::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 18px;
|
||
border: 1px solid rgba(var(--c-primary-rgb), 0.12);
|
||
background: radial-gradient(42% 32% at 50% 46%, rgba(var(--c-primary-rgb), 0.09), transparent 72%);
|
||
}
|
||
/* 柔皱光影:横向波纹随翻页从底部滚向顶部,模拟纸面弯曲传播 */
|
||
.curtain-root.intro-exit-paper .paper-fold {
|
||
display: block;
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 3;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
background:
|
||
linear-gradient(180deg, rgba(96, 66, 38, 0.16), rgba(255, 255, 255, 0) 24%),
|
||
repeating-linear-gradient(180deg,
|
||
rgba(255, 255, 255, 0) 0 9%,
|
||
rgba(113, 82, 50, 0.08) 12% 15%,
|
||
rgba(255, 255, 255, 0.22) 18% 21%,
|
||
rgba(255, 255, 255, 0) 25% 34%);
|
||
background-size: 100% 100%, 100% 240%;
|
||
background-position: 0 0, 0 130%;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-paper .curtain-emblem {
|
||
opacity: 1; transform: none; perspective: 1400px;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-paper .invite-cover {
|
||
transform-origin: 50% 0;
|
||
transform-style: preserve-3d;
|
||
backface-visibility: hidden;
|
||
will-change: transform, opacity, filter;
|
||
animation: paperFlipExit 1180ms cubic-bezier(0.55, 0, 0.3, 1) forwards;
|
||
}
|
||
/* 翻过 90° 后正面内容/装饰隐藏,只留纸背层(其自身反转 180° 恰好转正显示) */
|
||
.curtain-root.is-opening.intro-exit-paper .invite-cover > * {
|
||
backface-visibility: hidden;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-paper .paper-fold {
|
||
animation: paperFoldWave 1180ms cubic-bezier(0.55, 0, 0.3, 1) forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-paper .emblem-inner {
|
||
animation: inviteContentExit 420ms cubic-bezier(0.4, 0, 1, 1) 220ms forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-paper .curtain-door-left,
|
||
.curtain-root.is-opening.intro-exit-paper .curtain-door-right { transform: none; }
|
||
.curtain-root.is-opening.intro-exit-paper .curtain-bg,
|
||
.curtain-root.is-opening.intro-exit-paper .curtain-veil,
|
||
.curtain-root.is-opening.intro-exit-paper .curtain-door {
|
||
animation: bookBackdropFade 420ms ease-out 560ms forwards;
|
||
}
|
||
|
||
/* —— 穿越景深:镜头推进穿过请柬,背景同步放大退后 —— */
|
||
.curtain-root.is-opening.intro-exit-zoom .curtain-emblem {
|
||
opacity: 1; transform: none;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-zoom .invite-cover {
|
||
will-change: transform, opacity, filter;
|
||
animation: zoomThroughExit 1140ms cubic-bezier(0.62, 0, 0.22, 1) forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-zoom .emblem-inner {
|
||
animation: inviteContentExit 400ms cubic-bezier(0.4, 0, 1, 1) 180ms forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-zoom .curtain-door-left,
|
||
.curtain-root.is-opening.intro-exit-zoom .curtain-door-right { transform: none; }
|
||
.curtain-root.is-opening.intro-exit-zoom .curtain-bg {
|
||
animation: zoomBackdropBg 1140ms cubic-bezier(0.62, 0, 0.22, 1) forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-zoom .curtain-veil,
|
||
.curtain-root.is-opening.intro-exit-zoom .curtain-door {
|
||
animation: zoomBackdropFade 1140ms cubic-bezier(0.62, 0, 0.22, 1) forwards;
|
||
}
|
||
|
||
/* —— 圆窗收拢:整幅幕布以虹膜圆孔收拢谢幕 —— */
|
||
.curtain-root.is-opening.intro-exit-iris {
|
||
animation: irisClose 1200ms cubic-bezier(0.72, 0, 0.28, 1) 60ms forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-iris .curtain-emblem {
|
||
opacity: 1; transform: none;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-iris .invite-cover {
|
||
animation: irisCardSettle 1200ms ease forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-iris .emblem-inner {
|
||
animation: inviteContentExit 420ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||
}
|
||
|
||
/* —— 花瓣飘散:请柬化作花瓣散去 —— */
|
||
.petal-piece {
|
||
display: none;
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 44%;
|
||
width: 16px;
|
||
height: 12px;
|
||
z-index: 4;
|
||
border-radius: 62% 38% 58% 42% / 55% 45% 55% 45%;
|
||
background: linear-gradient(135deg, #f5cfd6, #e0a0ac);
|
||
box-shadow: 0 1px 3px rgba(150, 80, 95, 0.25);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-petal .petal-piece {
|
||
display: block;
|
||
animation: petalDrift 1420ms ease-out forwards;
|
||
}
|
||
.pp-1 { --dx: -34vw; --dy: -20vh; --rot: -240deg; animation-delay: 40ms; width: 15px; }
|
||
.pp-2 { --dx: 30vw; --dy: -26vh; --rot: 300deg; animation-delay: 120ms; background: linear-gradient(135deg, #fbe3e7, #e8b4bd); }
|
||
.pp-3 { --dx: -40vw; --dy: 8vh; --rot: -420deg; animation-delay: 200ms; width: 12px; height: 9px; }
|
||
.pp-4 { --dx: 38vw; --dy: 14vh; --rot: 380deg; animation-delay: 90ms; }
|
||
.pp-5 { --dx: -18vw; --dy: -34vh; --rot: -320deg; animation-delay: 260ms; width: 13px; height: 10px; background: linear-gradient(135deg, #f9dade, #dfa4ae); }
|
||
.pp-6 { --dx: 16vw; --dy: 30vh; --rot: 260deg; animation-delay: 170ms; }
|
||
.pp-7 { --dx: 2vw; --dy: -40vh; --rot: 200deg; animation-delay: 320ms; width: 11px; height: 8px; }
|
||
.curtain-root.is-opening.intro-exit-petal .curtain-emblem {
|
||
opacity: 1; transform: none;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-petal .invite-cover {
|
||
will-change: transform, opacity, filter;
|
||
animation: petalCoverExit 1260ms cubic-bezier(0.5, 0, 0.3, 1) forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-petal .emblem-inner {
|
||
animation: inviteContentExit 460ms cubic-bezier(0.4, 0, 1, 1) 120ms forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-petal .curtain-bg,
|
||
.curtain-root.is-opening.intro-exit-petal .curtain-veil,
|
||
.curtain-root.is-opening.intro-exit-petal .curtain-door {
|
||
animation: bookBackdropFade 560ms ease-out 620ms forwards;
|
||
}
|
||
.curtain-root.is-opening.intro-exit-petal .curtain-door-left,
|
||
.curtain-root.is-opening.intro-exit-petal .curtain-door-right { transform: none; }
|
||
.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: var(--c-ink-soft); 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),
|
||
var(--c-paper);
|
||
border: 1px solid rgba(var(--c-primary-rgb),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(var(--c-primary-rgb),0.14);
|
||
}
|
||
.invite-cover::before,
|
||
.invite-cover::after {
|
||
content: ''; position: absolute; pointer-events: none;
|
||
}
|
||
.invite-cover::before {
|
||
inset: 18px; border: 1px solid rgba(var(--c-primary-rgb),0.25);
|
||
}
|
||
.invite-cover::after {
|
||
inset: 28px; border: 1px solid rgba(var(--c-primary-rgb),0.11);
|
||
background:
|
||
linear-gradient(45deg, transparent 0 49.6%, rgba(var(--c-primary-rgb),0.15) 50%, transparent 50.4%),
|
||
linear-gradient(-45deg, transparent 0 49.6%, rgba(var(--c-primary-rgb),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(var(--c-primary-rgb),0.42);
|
||
}
|
||
.cover-filigree::before,
|
||
.cover-filigree::after {
|
||
content: ''; position: absolute; top: -4px; width: 26px; height: 8px;
|
||
border-top: 1px solid rgba(var(--c-primary-rgb),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: var(--c-ink-soft); padding: 42px 16px 34px; }
|
||
.emblem-kicker { display: block; letter-spacing: 0.48em; font-size: 18px; color: var(--c-primary); 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(var(--c-primary-rgb),0.46); color: var(--c-primary);
|
||
background: rgba(255,255,255,0.34); box-shadow: inset 0 0 0 5px rgba(var(--c-primary-rgb),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: var(--c-ink-soft); }
|
||
.emblem-names .amp { font-style: italic; color: var(--c-line); margin: 0 9px; font-family: var(--font-calligraphy); font-weight: 700; }
|
||
.emblem-rule { width: 72px; height: 1px; background: linear-gradient(90deg, transparent, var(--c-line), transparent); margin: 22px auto 18px; }
|
||
.emblem-date { font-family: var(--font-calligraphy); font-size: 12px; letter-spacing: 0.34em; padding-left: 0.34em; color: var(--c-primary-deep); }
|
||
.emblem-invite-title {
|
||
margin-top: 30px; color: var(--c-ink-soft); 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: var(--c-ink-soft);
|
||
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: var(--c-primary-deep);
|
||
display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||
.emblem-hint .dot { width: 6px; height: 6px; border-radius: 9999px; background: var(--c-line); animation: hintPulse 1.6s infinite; }
|
||
|
||
/* ---------- 章节指示点 ---------- */
|
||
.section-dots {
|
||
position: fixed;
|
||
right: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
z-index: 35;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.5s ease;
|
||
}
|
||
.section-dots.visible {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
.section-dot {
|
||
position: relative;
|
||
width: 6px;
|
||
height: 6px;
|
||
padding: 0;
|
||
border-radius: 9999px;
|
||
border: 1px solid rgba(var(--c-primary-rgb), 0.55);
|
||
background: rgba(255, 255, 255, 0.65);
|
||
cursor: pointer;
|
||
transition: height 0.3s ease, background 0.3s ease, border-color 0.3s ease;
|
||
}
|
||
.section-dot::before {
|
||
/* 扩大触控热区 */
|
||
content: '';
|
||
position: absolute;
|
||
inset: -7px;
|
||
}
|
||
.section-dot.active {
|
||
height: 18px;
|
||
background: linear-gradient(180deg, #c9a978, var(--c-primary));
|
||
border-color: var(--c-primary);
|
||
}
|
||
|
||
/* ---------- 尾页 ---------- */
|
||
.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(var(--c-primary-rgb),0.26); border-radius: 160px 160px 24px 24px;
|
||
background: rgba(255,255,255,0.28); pointer-events: none;
|
||
/* 内侧淡金光晕,增强"门内"纵深感 */
|
||
box-shadow: inset 0 0 46px rgba(201, 169, 120, 0.13);
|
||
}
|
||
.ending-frame::before {
|
||
content: ''; position: absolute; inset: 9px;
|
||
border: 0.5px solid rgba(var(--c-primary-rgb),0.18); border-radius: 150px 150px 18px 18px;
|
||
}
|
||
/* 拱顶 keystone:花饰圆章 + 两侧延伸细线,圆章用页面底色垫片盖住穿过的拱线 */
|
||
.ending-keystone {
|
||
position: absolute; top: 0; left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
display: flex; align-items: center; gap: 7px;
|
||
color: #b08e63;
|
||
}
|
||
.ek-mark {
|
||
width: 34px; height: 34px; border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 15px; line-height: 1;
|
||
background: var(--c-paper);
|
||
border: 1px solid rgba(var(--c-primary-rgb), 0.38);
|
||
box-shadow: 0 0 0 4px var(--c-paper), inset 0 0 8px rgba(201, 169, 120, 0.2);
|
||
}
|
||
.ek-line {
|
||
width: 24px; height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(var(--c-primary-rgb), 0.55));
|
||
}
|
||
.ek-line-r { transform: scaleX(-1); }
|
||
.reveal--in .ek-mark {
|
||
animation: keystonePop 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.35s both;
|
||
}
|
||
@keyframes keystonePop {
|
||
0% { opacity: 0; transform: scale(0.4) rotate(-90deg); }
|
||
100% { opacity: 1; transform: scale(1) rotate(0deg); }
|
||
}
|
||
/* 门框底部两侧对称藤蔓花枝 */
|
||
.ending-vine {
|
||
position: absolute; bottom: 10px;
|
||
width: 74px; height: 48px;
|
||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 60'%3E%3Cg fill='none' stroke='%23a88c6b' stroke-width='1.2'%3E%3Cpath d='M4 56C24 50 44 40 60 26 70 17 78 10 92 6'/%3E%3Cpath d='M24 48q-3-9 4-14 4 7-4 14z' fill='%23c9a978' stroke='none' opacity='.6'/%3E%3Cpath d='M42 38q-1-9 7-12 2 8-7 12z' fill='%23c9a978' stroke='none' opacity='.55'/%3E%3Cpath d='M60 26q0-9 8-11 1 8-8 11z' fill='%23c9a978' stroke='none' opacity='.5'/%3E%3Cpath d='M76 15q1-8 9-9 0 8-9 9z' fill='%23c9a978' stroke='none' opacity='.45'/%3E%3Ccircle cx='92' cy='6' r='2' fill='%23d9a0a5' stroke='none' opacity='.65'/%3E%3C/g%3E%3C/svg%3E") no-repeat center / contain;
|
||
opacity: 0.55;
|
||
}
|
||
.ending-vine-l { left: 10px; }
|
||
.ending-vine-r { right: 10px; transform: scaleX(-1); }
|
||
/* 四角雕花:细线 L 角 + 圆点收尾
|
||
容器必须自带 absolute 尺寸:v-reveal 的 transform 会把它变成
|
||
absolute 子元素的 containing block,没有 inset 时入场动画期间四角会错位 */
|
||
.ending-corners {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
}
|
||
.ending-corners i {
|
||
position: absolute; width: 46px; height: 46px; border-color: rgba(var(--c-primary-rgb),0.3);
|
||
}
|
||
.ending-corners i::after {
|
||
content: ''; position: absolute; width: 4px; height: 4px;
|
||
border-radius: 50%; background: rgba(var(--c-primary-rgb), 0.42);
|
||
}
|
||
.ending-corners i:nth-child(1) { top: 44px; left: 38px; border-top: 1px solid; border-left: 1px solid; }
|
||
.ending-corners i:nth-child(1)::after { top: -2.5px; left: -2.5px; }
|
||
.ending-corners i:nth-child(2) { top: 44px; right: 38px; border-top: 1px solid; border-right: 1px solid; }
|
||
.ending-corners i:nth-child(2)::after { top: -2.5px; right: -2.5px; }
|
||
.ending-corners i:nth-child(3) { left: 38px; bottom: 44px; border-bottom: 1px solid; border-left: 1px solid; }
|
||
.ending-corners i:nth-child(3)::after { bottom: -2.5px; left: -2.5px; }
|
||
.ending-corners i:nth-child(4) { right: 38px; bottom: 44px; border-right: 1px solid; border-bottom: 1px solid; }
|
||
.ending-corners i:nth-child(4)::after { bottom: -2.5px; right: -2.5px; }
|
||
.ending-kicker {
|
||
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||
margin-bottom: 18px; color: var(--c-primary); 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(var(--c-primary-rgb),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(var(--c-primary-rgb),0.18);
|
||
background: var(--c-bg);
|
||
}
|
||
.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: var(--c-primary); 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: var(--c-muted); 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(var(--c-primary-rgb),0.18);
|
||
border-bottom: 1px solid rgba(var(--c-primary-rgb),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: color-mix(in srgb, var(--c-card) 62%, transparent);
|
||
color: var(--c-primary-deep); 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(var(--c-line-rgb),0.12);
|
||
}
|
||
.rsvp-cta:hover { background: var(--c-primary); color: #fff; border-color: var(--c-primary); box-shadow: 0 10px 26px rgba(var(--c-line-rgb),0.28); }
|
||
.rsvp-cta:active { transform: scale(0.97); transition-duration: 0.12s; }
|
||
.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(var(--c-primary-rgb),0.22);
|
||
border-bottom: 1px solid rgba(var(--c-primary-rgb),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, transform .2s ease;
|
||
}
|
||
.map-cta:hover {
|
||
background: rgba(255,255,255,0.42);
|
||
border-color: rgba(var(--c-primary-rgb),0.34);
|
||
}
|
||
.map-cta:active { transform: scale(0.985); transition-duration: 0.1s; }
|
||
.map-cta-icon {
|
||
width: 28px; height: 28px; border-radius: 9999px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
border: 1px solid rgba(var(--c-primary-rgb),0.38); color: var(--c-primary);
|
||
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: var(--c-muted); font-size: 9px; letter-spacing: 0.08em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.map-cta-arrow {
|
||
color: var(--c-primary); font-size: 10px; letter-spacing: 0.18em; padding: 0 1px 0 10px;
|
||
border-left: 1px solid rgba(var(--c-primary-rgb),0.2);
|
||
}
|
||
|
||
/* ---------- 地图选择 ---------- */
|
||
.map-sheet {
|
||
width: min(100%, 360px); border-radius: 20px; padding: 14px;
|
||
background: color-mix(in srgb, var(--c-card) 96%, transparent); border: 1px solid rgba(var(--c-primary-rgb),0.18);
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.24); backdrop-filter: blur(14px);
|
||
}
|
||
.map-sheet-title {
|
||
text-align: center; color: var(--c-primary-deep); 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: var(--c-paper); color: var(--c-ink-soft); 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: var(--c-primary); }
|
||
.map-sheet button:hover { background: rgba(var(--c-primary-rgb),0.14); color: var(--c-primary-deep); }
|
||
.map-sheet .map-sheet-cancel { color: var(--c-muted); 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(var(--c-primary-rgb), 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(var(--c-primary-rgb),0.35); background: var(--c-paper); color: var(--c-muted);
|
||
font-size: 12px; letter-spacing: 0.1em; cursor: pointer; transition: all .25s ease;
|
||
}
|
||
.rsvp-tag:hover { border-color: var(--c-primary); }
|
||
.rsvp-tag.active { background: var(--c-primary); border-color: var(--c-primary); color: #fff; box-shadow: 0 4px 12px rgba(var(--c-primary-rgb),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: color-mix(in srgb, var(--c-card) 95%, transparent);
|
||
border: 1px solid rgba(var(--c-primary-rgb),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: var(--c-primary); 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(var(--c-primary-rgb),0.1); }
|
||
.track-item.active { background: rgba(var(--c-primary-rgb),0.16); color: var(--c-primary); }
|
||
.track-item .tname { font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.track-item .tcheck { font-size: 12px; color: var(--c-primary); }
|
||
.eq { display: inline-flex; align-items: flex-end; gap: 2px; width: 14px; }
|
||
.eq i { width: 3px; background: var(--c-primary); 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(var(--c-primary-rgb),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(var(--c-primary-rgb),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(var(--c-primary-rgb),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(var(--c-primary-rgb),0.25);
|
||
box-shadow: none;
|
||
}
|
||
34% {
|
||
border-color: rgba(var(--c-primary-rgb),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(var(--c-primary-rgb),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(var(--c-primary-rgb),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); }
|
||
}
|
||
/* 信封启封 */
|
||
@keyframes envelopeSealPop {
|
||
0% { transform: translateX(-50%) scale(1) rotate(0); opacity: 1; }
|
||
42% { transform: translateX(-50%) scale(1.16) rotate(-9deg); opacity: 1; }
|
||
100% { transform: translateX(-50%) scale(0.55) rotate(16deg) translateY(-10px); opacity: 0; }
|
||
}
|
||
@keyframes envelopeFlapOpen {
|
||
0% { transform: rotateX(0); }
|
||
100% { transform: rotateX(-176deg); }
|
||
}
|
||
@keyframes envelopeCardOut {
|
||
0% { opacity: 1; transform: translateY(0) rotate(0); }
|
||
16% { opacity: 1; transform: translateY(10px) rotate(0.4deg); }
|
||
100% { opacity: 0; transform: translateY(-118vh) rotate(-2.6deg); }
|
||
}
|
||
/* 贺卡掀页:先压角蓄力,翻页中段最快并带柔性左右摆动(skew),落定前减速 */
|
||
@keyframes paperFlipExit {
|
||
0% { opacity: 1; transform: rotateX(0) skewX(0) translateZ(0); filter: brightness(1); }
|
||
10% { opacity: 1; transform: rotateX(5deg) skewX(0.5deg) translateZ(2px); filter: brightness(1.01); }
|
||
24% { opacity: 1; transform: rotateX(-22deg) skewX(-1.2deg) translateZ(9px); filter: brightness(1.05); }
|
||
44% { opacity: 1; transform: rotateX(-74deg) skewX(1.6deg) translateZ(17px); filter: brightness(1.08); }
|
||
62% { opacity: 1; transform: rotateX(-124deg) skewX(-1.3deg) translateZ(19px); filter: brightness(0.97); }
|
||
82% { opacity: 0.95; transform: rotateX(-161deg) skewX(0.7deg) translateZ(11px); filter: brightness(0.9); }
|
||
100% { opacity: 0; transform: rotateX(-179deg) skewX(0) translateZ(4px); filter: brightness(0.85); }
|
||
}
|
||
/* 纸面柔皱波纹:翻页时从底部滚向顶部并淡出 */
|
||
@keyframes paperFoldWave {
|
||
0% { opacity: 0; background-position: 0 0, 0 130%; }
|
||
16% { opacity: 0.95; }
|
||
46% { opacity: 0.88; background-position: 0 0, 0 22%; }
|
||
74% { opacity: 0.75; background-position: 0 0, 0 -52%; }
|
||
100% { opacity: 0; background-position: 0 0, 0 -130%; }
|
||
}
|
||
/* 穿越景深 */
|
||
@keyframes zoomThroughExit {
|
||
0% { opacity: 1; transform: scale(1); filter: blur(0); }
|
||
22% { opacity: 1; transform: scale(0.965); filter: blur(0); }
|
||
62% { opacity: 1; filter: blur(1px); }
|
||
100% { opacity: 0; transform: scale(7.4); filter: blur(12px); }
|
||
}
|
||
@keyframes zoomBackdropBg {
|
||
0% { opacity: 1; transform: scale(1.12); }
|
||
100% { opacity: 0; transform: scale(1.78); }
|
||
}
|
||
@keyframes zoomBackdropFade {
|
||
0% { opacity: 1; transform: scale(1); }
|
||
100% { opacity: 0; transform: scale(1.32); }
|
||
}
|
||
/* 圆窗收拢 */
|
||
@keyframes irisClose {
|
||
0% { clip-path: circle(142% at 50% 46%); }
|
||
100% { clip-path: circle(0% at 50% 46%); }
|
||
}
|
||
@keyframes irisCardSettle {
|
||
0% { transform: scale(1); opacity: 1; }
|
||
100% { transform: scale(0.9); opacity: 0.55; }
|
||
}
|
||
/* 花瓣飘散 */
|
||
@keyframes petalDrift {
|
||
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.4) rotate(0); }
|
||
12% { opacity: 0.96; }
|
||
70% { opacity: 0.8; }
|
||
100% {
|
||
opacity: 0;
|
||
transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(1.05) rotate(var(--rot));
|
||
}
|
||
}
|
||
@keyframes petalCoverExit {
|
||
0% { opacity: 1; transform: scale(1) translateY(0); filter: blur(0); }
|
||
18% { opacity: 1; transform: scale(1.015) translateY(-4px); filter: blur(0); }
|
||
100% { opacity: 0; transform: scale(0.84) translateY(-30px); filter: blur(9px); }
|
||
}
|
||
|
||
/* ---------- PC:内容列限宽居中(缩略图不宜铺满大屏) ---------- */
|
||
@media (min-width: 768px) {
|
||
.wrapper {
|
||
--pc-invite-width: 430px;
|
||
background:
|
||
radial-gradient(ellipse at 50% 12%, rgba(232, 201, 210, 0.12), transparent 42%),
|
||
linear-gradient(180deg, var(--c-bg) 0%, var(--c-bg-deep) 100%);
|
||
}
|
||
.scroll-transform-host {
|
||
max-width: var(--pc-invite-width);
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
background: var(--c-paper);
|
||
box-shadow:
|
||
0 0 0 1px rgba(var(--c-primary-rgb), 0.12),
|
||
0 28px 80px rgba(95, 70, 39, 0.12);
|
||
}
|
||
.scroll-progress {
|
||
left: 50%;
|
||
right: auto;
|
||
width: var(--pc-invite-width);
|
||
max-width: 100%;
|
||
transform: translateX(-50%);
|
||
}
|
||
.action-dock {
|
||
left: 50%;
|
||
right: auto;
|
||
width: var(--pc-invite-width);
|
||
max-width: 100%;
|
||
transform: translateX(-50%);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 日历 PC:略放大卡片与选中爱心,移动端样式不变 */
|
||
.wedding-calendar-page {
|
||
min-height: 100dvh;
|
||
padding-top: 48px;
|
||
padding-bottom: 48px;
|
||
}
|
||
.calendar-card {
|
||
width: min(100%, 392px);
|
||
padding: 36px 28px 34px;
|
||
}
|
||
.calendar-card::before {
|
||
inset: 14px;
|
||
}
|
||
.calendar-kicker {
|
||
font-size: 11px;
|
||
letter-spacing: 0.5em;
|
||
padding-left: 0.5em;
|
||
}
|
||
.calendar-title {
|
||
margin-top: 14px;
|
||
font-size: 34px;
|
||
gap: 16px;
|
||
}
|
||
.calendar-title i {
|
||
width: 40px;
|
||
}
|
||
.calendar-date-line {
|
||
margin: 12px auto 26px;
|
||
font-size: 12px;
|
||
}
|
||
.calendar-weekdays,
|
||
.calendar-grid {
|
||
gap: 10px;
|
||
}
|
||
.calendar-weekdays {
|
||
margin-bottom: 11px;
|
||
font-size: 11px;
|
||
}
|
||
.calendar-day {
|
||
font-size: 16px;
|
||
}
|
||
.calendar-day.is-selected .calendar-day-num {
|
||
width: 28px;
|
||
height: 28px;
|
||
font-size: 17px;
|
||
}
|
||
.heart-ring {
|
||
inset: -12px;
|
||
}
|
||
.heart-ring svg {
|
||
width: 74px;
|
||
height: 72px;
|
||
}
|
||
.calendar-note {
|
||
margin-top: 28px;
|
||
font-size: 14px;
|
||
gap: 12px;
|
||
}
|
||
}
|
||
</style>
|