@@ -852,6 +894,7 @@ 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)
@@ -1480,9 +1523,29 @@ const openImagePreview = async (src) => {
}
const effectsActive = computed(() =>
- !isDrawerOpen.value && !showBlessingDrawer.value && !showGiftPanel.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
+ !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
@@ -1733,6 +1796,14 @@ const completeIntro = () => {
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
@@ -1745,7 +1816,6 @@ const completeIntro = () => {
nextTick(() => giftEffectRef.value?.playSequence(list, { max: 12 }))
}
}
- scheduleAutoScrollStart()
}
const openInvitation = () => {
@@ -2014,8 +2084,8 @@ const loadLikeStatus = async () => {
const count = Number(res.data?.count) || 0
likeCount.value = count
if (count <= 0) return
- // 揭幕中则等结束后再播,避免帘幕后白播
- if (showIntro.value) pendingLikeBurst = count
+ // Loading / 揭幕中则等结束后再播,避免帘幕后白播
+ if (shouldDeferEntranceFx()) pendingLikeBurst = count
else nextTick(() => likeBurstRef.value?.burst(count, { durationMs: 2800, maxVisual: 10 }))
} catch { /* ignore */ }
}
@@ -2028,7 +2098,7 @@ const loadGiftStatus = async () => {
const recent = Array.isArray(res.data?.recent) ? res.data.recent : []
const aggregated = aggregateGiftRecent(recent)
if (!aggregated.length) return
- if (showIntro.value) pendingGiftReplay.value = aggregated
+ if (shouldDeferEntranceFx()) pendingGiftReplay.value = aggregated
else if (showGiftFx.value) nextTick(() => giftEffectRef.value?.playSequence(aggregated, { max: 12 }))
} catch { /* ignore */ }
}
@@ -2059,6 +2129,8 @@ const handleLike = async (count = 1) => {
onMounted(async () => {
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
window.scrollTo(0, 0)
+ const bootStartedAt = Date.now()
+ const BOOT_MIN_MS = 1100
try {
const res = await getConfig()
@@ -2106,7 +2178,18 @@ onMounted(async () => {
loadGiftStatus()
sendVisitBeacon()
+ // Loading 期间预热封面,避免揭幕后闪空图
+ await preloadImage(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()
const pickedUrl = pickMusicUrl(
data.value.musicList,
@@ -3385,6 +3468,264 @@ watch(
width: 20px; height: 1px; background: rgba(168,140,107,0.45);
}
+/* ---------- 揭幕前 Loading(象牙金主题) ---------- */
+.boot-loading {
+ --boot-gold: #a88c6b;
+ --boot-gold-soft: #b89a78;
+ --boot-ink: #5b4a36;
+ --boot-cream: #fbf6ef;
+ 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(184, 154, 120, 0.14), transparent 42%),
+ radial-gradient(ellipse at 82% 18%, rgba(168, 140, 107, 0.12), transparent 40%),
+ linear-gradient(165deg, #fdfbf7 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(184, 154, 120, 0.28);
+}
+.boot-glow-2 {
+ width: 220px; height: 220px; right: 4%; bottom: 16%;
+ background: rgba(168, 140, 107, 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(184, 154, 120, 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(168, 140, 107, 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(168, 140, 107, 0.55));
+}
+.boot-rule span:last-child {
+ background: linear-gradient(90deg, rgba(168, 140, 107, 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(168, 140, 107, 0.72);
+ border-right-color: rgba(184, 154, 120, 0.18);
+ animation: boot-ring-spin 1.8s linear infinite;
+}
+.boot-seal-ring-b {
+ inset: -2px;
+ border-width: 1px;
+ border-bottom-color: rgba(184, 154, 120, 0.55);
+ border-left-color: rgba(168, 140, 107, 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(168, 140, 107, 0.55);
+ box-shadow:
+ inset 0 0 0 5px rgba(168, 140, 107, 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(168, 140, 107, 0.08), 0 12px 32px rgba(120, 90, 55, 0.14); }
+ 50% { transform: scale(1.035); box-shadow: inset 0 0 0 5px rgba(168, 140, 107, 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: #fbf6ef; perspective: 1200px; }
.curtain-bg {