Files
hunli/vite-tailwindcss/src/views/index/index.vue
2026-08-12 13:52:54 +08:00

5068 lines
192 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="wrapper relative w-full min-h-[100dvh] bg-[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>&amp;</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>&amp;</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">&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 }} &amp; {{ data.bride }} · {{ weddingDateText }}</div>
<div class="calendar-weekdays">
<span v-for="w in calendarWeekdays" :key="w">{{ w }}</span>
</div>
<div class="calendar-grid">
<span
v-for="(day, idx) in calendarDays"
:key="idx"
class="calendar-day"
:class="{ 'is-empty': !day, 'is-selected': day === weddingDay }"
>
<span v-if="day" class="calendar-day-num">{{ day }}</span>
<span v-if="day === weddingDay" class="heart-ring" aria-hidden="true">
<svg viewBox="0 0 120 96" role="img">
<defs>
<filter id="calendar-heart-rough" x="-18%" y="-18%" width="136%" height="136%">
<feTurbulence type="fractalNoise" baseFrequency="0.045" numOctaves="3" seed="11" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.8" />
</filter>
</defs>
<g filter="url(#calendar-heart-rough)">
<path class="heart-brush heart-brush-shadow" d="M54 82 C31 70 12 50 13 29 C14 10 35 7 49 27 C56 37 58 49 59 56 C64 37 80 10 99 20 C113 28 105 51 90 66 C80 76 70 81 64 83" />
<path class="heart-brush heart-brush-main" d="M54 82 C31 70 12 50 13 29 C14 10 35 7 49 27 C56 37 58 49 59 56 C64 37 80 10 99 20 C113 28 105 51 90 66 C80 76 70 81 64 83" />
<path class="heart-brush heart-brush-tail" d="M94 61 C87 62 82 58 78 52" />
</g>
<g class="heart-brush-cuts">
<path d="M24 28 C34 15 45 23 51 39" />
<path d="M70 43 C78 25 91 22 101 30" />
<path d="M28 48 C39 59 47 65 55 70" />
<path d="M78 68 C86 64 94 57 98 49" />
</g>
<g class="heart-brush-speckles">
<circle cx="14" cy="44" r="1.9" />
<circle cx="22" cy="19" r="1.2" />
<circle cx="101" cy="22" r="1.8" />
<circle cx="109" cy="48" r="1.4" />
<circle cx="74" cy="77" r="1.2" />
</g>
</svg>
</span>
</span>
</div>
<div class="calendar-note">
<span>良辰已定</span>
<i></i>
<span>敬候光临</span>
</div>
</div>
</div>
<!-- 婚礼流程 -->
<div class="page-section schedule-page px-6 z-10 justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<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 }} &amp; {{ data.bride }}</span>
<span class="ending-date">{{ data.date }}</span>
</div>
<div v-reveal="{ variant: 'up', delay: 240 }" class="ending-venue">
<span class="ending-hotel">{{ data.hotel }}</span>
<span class="ending-address">{{ data.address }}</span>
</div>
<div v-reveal="{ variant: 'up', delay: 300 }" class="ending-actions w-full max-w-[280px]">
<button class="rsvp-cta" @click="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>