更新页面效果

This commit is contained in:
李琦
2026-07-31 14:43:54 +08:00
parent 1ce1272dbb
commit 27f084a3c0
4 changed files with 456 additions and 138 deletions

View File

@@ -0,0 +1,177 @@
<template>
<canvas ref="canvasRef" class="canvas-effects" aria-hidden="true"></canvas>
</template>
<script setup>
import { onMounted, onUnmounted, watch, ref } from 'vue'
const props = defineProps({
petalEnabled: { type: Boolean, default: true },
bubbleEnabled: { type: Boolean, default: true },
})
const canvasRef = ref(null)
const petals = []
const hearts = []
const petalColors = ['#f7dce3', '#fbeee3', '#f3c9d6', '#efdcc8', '#f9e7d6']
const heartColors = ['#c9b08a', '#e2b3c0', '#d4af37', '#e8c9d2']
let ctx = null
let rafId = 0
let width = 0
let height = 0
let dpr = 1
let lastTime = 0
let resizeObserver = null
let reduceMotion = false
const rand = (min, max) => min + Math.random() * (max - min)
function resetPetal(p, initial = false) {
p.x = rand(0, width)
p.y = initial ? rand(-height, height) : rand(-80, -20)
p.size = rand(8, 22)
p.speed = rand(18, 42)
p.sway = rand(12, 34)
p.phase = rand(0, Math.PI * 2)
p.rotation = rand(0, Math.PI * 2)
p.rotationSpeed = rand(-0.9, 0.9)
p.opacity = rand(0.28, 0.7)
p.color = petalColors[Math.floor(rand(0, petalColors.length))]
}
function resetHeart(h, initial = false) {
h.x = rand(0, width)
h.y = initial ? rand(0, height) : height + rand(20, 90)
h.size = rand(10, 22)
h.speed = rand(12, 32)
h.sway = rand(8, 26)
h.phase = rand(0, Math.PI * 2)
h.opacity = rand(0.1, 0.32)
h.color = heartColors[Math.floor(rand(0, heartColors.length))]
}
function seedParticles() {
petals.length = 0
hearts.length = 0
for (let i = 0; i < 18; i += 1) {
const p = {}
resetPetal(p, true)
petals.push(p)
}
for (let i = 0; i < 12; i += 1) {
const h = {}
resetHeart(h, true)
hearts.push(h)
}
}
function resize() {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
width = Math.max(1, rect.width)
height = Math.max(1, rect.height)
dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
ctx = canvas.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
seedParticles()
}
function drawPetal(p, now) {
const x = p.x + Math.sin(now * 0.0014 + p.phase) * p.sway
ctx.save()
ctx.translate(x, p.y)
ctx.rotate(p.rotation)
ctx.globalAlpha = p.opacity
ctx.fillStyle = p.color
ctx.beginPath()
ctx.moveTo(0, -p.size * 0.56)
ctx.bezierCurveTo(-p.size * 0.58, -p.size * 0.18, -p.size * 0.36, p.size * 0.48, 0, p.size * 0.58)
ctx.bezierCurveTo(p.size * 0.36, p.size * 0.48, p.size * 0.58, -p.size * 0.18, 0, -p.size * 0.56)
ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,0.42)'
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.moveTo(0, -p.size * 0.38)
ctx.lineTo(0, p.size * 0.42)
ctx.stroke()
ctx.restore()
}
function drawHeart(h, now) {
const x = h.x + Math.sin(now * 0.001 + h.phase) * h.sway
const s = h.size / 18
ctx.save()
ctx.translate(x, h.y)
ctx.scale(s, s)
ctx.globalAlpha = h.opacity
ctx.fillStyle = h.color
ctx.beginPath()
ctx.moveTo(0, 7)
ctx.bezierCurveTo(-18, -6, -10, -22, 0, -12)
ctx.bezierCurveTo(10, -22, 18, -6, 0, 7)
ctx.fill()
ctx.restore()
}
function tick(now) {
if (!ctx) return
const delta = Math.min(48, now - (lastTime || now)) / 1000
lastTime = now
ctx.clearRect(0, 0, width, height)
if (!reduceMotion && props.petalEnabled) {
for (const p of petals) {
p.y += p.speed * delta
p.rotation += p.rotationSpeed * delta
if (p.y > height + 80) resetPetal(p)
drawPetal(p, now)
}
}
if (!reduceMotion && props.bubbleEnabled) {
for (const h of hearts) {
h.y -= h.speed * delta
if (h.y < -80) resetHeart(h)
drawHeart(h, now)
}
}
rafId = requestAnimationFrame(tick)
}
function start() {
cancelAnimationFrame(rafId)
lastTime = 0
rafId = requestAnimationFrame(tick)
}
onMounted(() => {
reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches || false
resize()
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(canvasRef.value)
start()
})
watch(() => [props.petalEnabled, props.bubbleEnabled], start)
onUnmounted(() => {
cancelAnimationFrame(rafId)
resizeObserver?.disconnect()
})
</script>
<style scoped>
.canvas-effects {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>

View File

@@ -2,9 +2,9 @@
<div class="gallery">
<!-- 标题区 -->
<div v-reveal="'up'" class="text-center mb-5 px-4">
<span class="text-xl text-[#a88c6b] tracking-[0.3em] font-light block mb-2">{{ page.title }}</span>
<span class="text-[9px] text-[#8A8680] uppercase tracking-[0.4em] block mb-4">{{ page.subtitle }}</span>
<span class="text-[12px] text-[#6b6863] font-light leading-relaxed mx-auto max-w-[280px] tracking-widest block whitespace-pre-wrap">{{ page.desc }}</span>
<span class="gallery-title text-xl text-[#a88c6b] tracking-[0.3em] font-light block mb-2">{{ page.title }}</span>
<span class="gallery-subtitle text-[9px] text-[#8A8680] uppercase tracking-[0.4em] block mb-4">{{ page.subtitle }}</span>
<span class="gallery-desc text-[12px] text-[#6b6863] font-light leading-relaxed mx-auto max-w-[280px] tracking-widest block whitespace-pre-wrap">{{ page.desc }}</span>
</div>
<!-- 单图默认居中卡幅 / 可配置宽度铺满 -->
@@ -139,7 +139,10 @@ function polaroidTransform(i) {
</script>
<style scoped>
.gallery { width: 100%; }
.gallery { width: 100%; font-family: var(--font-sans); }
.gallery-title { font-family: var(--font-serif); }
.gallery-subtitle { font-family: var(--font-display); }
.gallery-desc { font-family: var(--font-kai); }
/* 错落双图:高度随视口缩放并封顶,保证标题+图片总高始终落在页高预算内 */
.overlap-wrap { height: clamp(320px, 54vh, 460px); }

View File

@@ -1 +1,15 @@
@import "tailwindcss";
@import "tailwindcss";
:root {
--font-sans: "Avenir Next", "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-serif: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", "SimSun", serif;
--font-display: "Didot", "Bodoni 72", "Cormorant Garamond", "Times New Roman", serif;
--font-kai: "Kaiti SC", "STKaiti", "KaiTi", "FangSong", serif;
--font-brush: "HanziPen SC", "STXingkai", "华文行楷", "FZKai-Z03S", "Kaiti SC", "STKaiti", "KaiTi", "FangSong", serif;
--font-number: "Cochin", "Georgia", "Times New Roman", serif;
}
html,
body {
font-family: var(--font-sans);
}

View File

@@ -2,7 +2,7 @@
<div class="wrapper relative w-full h-[100vh] bg-[#Fdfbf7] overflow-hidden text-[#2c2c2c]">
<!-- 入场揭幕帘幕雅致双开门 + 徽标 -->
<div v-show="showIntro" class="curtain-root" :class="{ 'is-opening': introOpening }" @click="openInvitation">
<div v-show="showIntro" class="curtain-root" :class="{ 'is-opening': introOpening }" @click="openInvitation" @transitionend="onIntroTransitionEnd">
<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>
@@ -14,6 +14,8 @@
<div class="emblem-names">{{ data.groom }}<span class="amp">&amp;</span>{{ data.bride }}</div>
<div class="emblem-rule"></div>
<div class="emblem-date">{{ data.date }}</div>
<div class="emblem-invite-title">岁序更替</div>
<div class="emblem-invite-copy">始于初见止于终老时光煮雨人事静好盼与您分享我们的幸福时刻</div>
<div class="emblem-hint"><span class="dot"></span>轻触开启请柬</div>
</div>
</div>
@@ -22,25 +24,7 @@
<!-- 背景柔光层淡金/淡粉光斑缓慢漂移 -->
<div class="bg-glow" aria-hidden="true"><i class="g1"></i><i class="g2"></i><i class="g3"></i></div>
<!-- 飘落花瓣层外层位移 + 内层摇曳 transform 不触发布局 -->
<div v-if="data.petalEnabled" class="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
<div v-for="p in petals" :key="'p'+p.id" class="petal"
:style="{ left: p.left, width: p.size+'px', height: p.size+'px', opacity: p.opacity, animationDelay: p.delay, '--p-dur': p.duration }">
<svg viewBox="0 0 100 100" class="petal-inner" :style="{ '--p-sway': p.sway, filter: p.filter }">
<path d="M50 3 C28 26 20 62 50 97 C80 62 72 26 50 3 Z" :fill="p.color" opacity="0.9" />
<path d="M50 9 C50 36 50 70 50 91" stroke="rgba(255,255,255,0.55)" stroke-width="1.4" fill="none" opacity="0.5" />
</svg>
</div>
</div>
<!-- 漂浮爱心层三层景深远小淡模糊 / 近大清晰 -->
<div v-if="data.bubbleEnabled" class="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
<div v-for="b in bubbles" :key="'b'+b.id"
class="heart-bubble flex items-center justify-center"
:style="{ left: b.left, top: b.top, color: b.color, fontSize: b.size+'px', animationDelay: b.delay, '--float-duration': b.floatDuration, '--h-opacity': b.opacity }">
<span class="heart-inner" :style="{ '--sway-duration': b.swayDuration, filter: b.filter }"></span>
</div>
</div>
<CanvasEffects :petal-enabled="data.petalEnabled" :bubble-enabled="data.bubbleEnabled" />
<!-- 顶部滚动进度条 -->
<div class="absolute top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
@@ -89,7 +73,7 @@
<div class="page-section" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="flex flex-col items-center justify-between py-12 h-full w-full relative border-[0.5px] border-[#a88c6b]/20 bg-white/40">
<div v-reveal="'down'" class="text-center mt-6 z-10">
<span class="text-[10px] tracking-[0.5em] uppercase text-[#a88c6b] font-light block">Wedding Invitation</span>
<span class="cover-kicker text-[10px] tracking-[0.5em] uppercase text-[#a88c6b] font-light block">Wedding Invitation</span>
<div class="w-6 h-[0.5px] bg-[#a88c6b] mx-auto opacity-50 mt-4"></div>
</div>
@@ -101,12 +85,12 @@
</div>
<div v-reveal="{ variant: 'up', delay: 200 }" class="text-center z-10 mb-10 space-y-4 px-4">
<div class="flex items-end justify-center gap-6 text-3xl font-light tracking-[0.2em]">
<div class="cover-names flex items-end justify-center gap-6 text-3xl font-light tracking-[0.2em]">
<span>{{ data.groom }}</span>
<span class="text-[#a88c6b] text-xl pb-1 italic font-serif">&amp;</span>
<span>{{ data.bride }}</span>
</div>
<div class="flex flex-col items-center text-[11px] text-[#8A8680] tracking-[0.2em] mt-4 uppercase">
<div class="cover-date flex flex-col items-center text-[11px] text-[#8A8680] tracking-[0.2em] mt-4 uppercase">
<span>{{ data.date }}</span>
<div class="w-4 h-[0.5px] bg-[#a88c6b]/40 my-2"></div>
<span>{{ data.lunar }}</span>
@@ -120,11 +104,11 @@
<div class="absolute top-0 left-0 w-32 h-32 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6"></div>
<div class="absolute bottom-0 right-0 w-32 h-32 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6"></div>
<div v-reveal="'blur'" class="flex flex-col items-center w-full">
<div class="text-3xl opacity-60 mb-14 text-[#a88c6b] font-serif float-soft"></div>
<div class="flex justify-center gap-10 h-72 text-[#3A3A3A] text-lg leading-loose font-light">
<span class="vertical-text">{{ data.formalText1 }}</span>
<span class="vertical-text" style="padding-top: 3rem;">{{ data.formalText2 }}</span>
<span class="vertical-text text-[#8A8680] text-[12px] ml-4 border-l-[0.5px] border-[#a88c6b]/30 pl-6 h-56 mt-8 tracking-[0.3em]">诚挚邀请您见证我们的幸福</span>
<div class="text-3xl opacity-60 mb-12 text-[#a88c6b] font-serif float-soft"></div>
<div class="formal-copy flex justify-center gap-9 h-[22rem] text-[#3A3A3A]">
<span class="vertical-text formal-main">{{ data.formalText1 }}</span>
<span class="vertical-text formal-main formal-main-offset">{{ data.formalText2 }}</span>
<span class="vertical-text formal-side ml-4 border-l-[0.5px] border-[#a88c6b]/30 pl-6 h-64 mt-8 tracking-[0.28em]">诚挚邀请您见证我们的幸福</span>
</div>
</div>
</div>
@@ -143,8 +127,8 @@
<div class="page-section bg-transparent px-6 z-10 justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="absolute inset-0 bg-[#Fdfbf7]/60 backdrop-blur-[2px] -z-10"></div>
<div v-reveal="'up'" class="text-center mb-14">
<span class="text-2xl text-[#a88c6b] tracking-[0.3em] font-light block">婚礼流程</span>
<span class="text-[10px] text-[#8A8680] font-sans uppercase tracking-[0.4em] mt-3 block">Wedding Schedule</span>
<span class="schedule-title text-2xl text-[#a88c6b] tracking-[0.3em] font-light block">婚礼流程</span>
<span class="schedule-kicker text-[10px] text-[#8A8680] font-sans uppercase tracking-[0.4em] mt-3 block">Wedding Schedule</span>
</div>
<div class="relative w-full px-2 max-w-[340px] mx-auto">
<div class="absolute left-1/2 top-2 bottom-4 w-[0.5px] bg-[#a88c6b]/40 -translate-x-1/2"></div>
@@ -182,12 +166,20 @@
<span class="ending-hotel">{{ data.hotel }}</span>
<span class="ending-address">{{ data.address }}</span>
</div>
<div v-reveal="{ variant: 'up', delay: 300 }" class="w-full max-w-[280px]">
<div v-reveal="{ variant: 'up', delay: 300 }" class="ending-actions w-full max-w-[280px]">
<button class="rsvp-cta" @click="isDrawerOpen = true">
<span class="rsvp-cta-deco"></span>
<span class="rsvp-cta-text">填写出席回执</span>
<span class="rsvp-cta-line"></span>
</button>
<button class="map-cta" 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>
<i class="fa-solid fa-chevron-right map-cta-arrow"></i>
</button>
</div>
</div>
</div>
@@ -226,14 +218,13 @@
</div>
</div>
<!-- 音乐自动播放被浏览器拦截时才显示确认 -->
<div v-if="showMusicAuth" class="absolute inset-0 z-[80] bg-black/35 backdrop-blur-sm flex items-center justify-center px-6">
<div class="music-auth-card">
<div class="music-auth-note"></div>
<div class="music-auth-title">开启背景音乐</div>
<div class="music-auth-sub">浏览器需要一次确认才能播放</div>
<button class="music-auth-btn" type="button" @click="authorizeMusic">点击播放</button>
<button class="music-auth-skip" type="button" @click="onMusicAuthSkip">稍后再说</button>
<div v-if="showMapOptions" class="absolute 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>
@@ -244,6 +235,7 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { getConfig, submitRsvp } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
import CanvasEffects from '@/components/CanvasEffects.vue'
import PhotoGallery from '@/components/PhotoGallery.vue'
const DEFAULTS = {
@@ -273,11 +265,12 @@ const data = ref({
const audio = useAudio()
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSubmitted = ref(false)
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0)
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMusicAuth = ref(false)
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMapOptions = ref(false)
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
const BOTTOM_RETURN_DELAY = 30000
// 后台可配置的滚动参数(带默认值兜底,防止脏数据)
const freeInterval = computed(() => {
@@ -293,48 +286,6 @@ const firstScreenDuration = computed(() => {
return v >= 0 && v <= 30000 ? v : 4500
})
// 装饰元素配色(柔化后的低饱和金粉系,与主题协调不抢眼)
const loveColors = ['#c9b08a', '#e2b3c0', '#d4af37', '#e8c9d2']
const petalColors = ['#f7dce3', '#fbeee3', '#f3c9d6', '#efdcc8', '#f9e7d6']
// 爱心三层景深:远(小/淡/模糊) 中 近(大/清晰),营造纵深而不杂乱
const heartTiers = [
{ n: 5, size: [10, 13], opacity: [0.1, 0.16], blur: 1.6, dur: [20, 26] }, // 远景
{ n: 4, size: [14, 17], opacity: [0.16, 0.24], blur: 0.7, dur: [16, 21] }, // 中景
{ n: 3, size: [18, 22], opacity: [0.24, 0.32], blur: 0, dur: [13, 17] }, // 近景
]
let heartId = 0
const bubbles = heartTiers.flatMap((t) =>
Array.from({ length: t.n }).map(() => {
const id = heartId++
return {
id,
left: Math.random() * 100 + '%', top: Math.random() * 100 + '%',
color: loveColors[id % loveColors.length],
size: t.size[0] + Math.random() * (t.size[1] - t.size[0]),
opacity: +(t.opacity[0] + Math.random() * (t.opacity[1] - t.opacity[0])).toFixed(2),
filter: t.blur ? `blur(${t.blur}px)` : 'none',
floatDuration: (t.dur[0] + Math.random() * (t.dur[1] - t.dur[0])).toFixed(1) + 's',
swayDuration: (4.5 + Math.random() * 2.5).toFixed(1) + 's',
delay: '-' + (Math.random() * 20).toFixed(1) + 's',
}
})
)
// 花瓣:两层景深,远处更小更淡带轻微模糊
const petals = Array.from({ length: 16 }).map((_, i) => {
const far = i % 3 === 0
return {
id: i, left: Math.random() * 100 + '%',
size: far ? 8 + Math.random() * 7 : 13 + Math.random() * 11,
opacity: far ? 0.3 + Math.random() * 0.15 : 0.42 + Math.random() * 0.3,
color: petalColors[i % petalColors.length],
filter: far ? 'blur(1.2px) drop-shadow(0 2px 3px rgba(150,110,90,0.08))' : 'drop-shadow(0 2px 3px rgba(150,110,90,0.12))',
duration: (far ? 13 : 9) + Math.random() * 8 + 's',
delay: (Math.random() * 10) + 's',
sway: (4 + Math.random() * 4) + 's',
}
})
const curtainBg = computed(() => ({ backgroundImage: `url(${data.value.heroImg})` }))
const endImage = computed(() => data.value.endImg || data.value.heroImg)
@@ -345,15 +296,13 @@ const pageHeightStyle = (page) => {
}
const closeDrawer = () => { isDrawerOpen.value = false }
const authorizeMusic = async () => {
const ok = await audio.play()
if (ok) showMusicAuth.value = false
}
const handleScroll = () => {
const el = scrollContainerRef.value
if (!el) return
const max = el.scrollHeight - el.clientHeight
progress.value = max > 0 ? el.scrollTop / max : 0
if (isAtBottom(el)) scheduleBottomReturn()
else clearBottomReturn()
}
const handleUserInteraction = () => {
@@ -361,11 +310,36 @@ const handleUserInteraction = () => {
// 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动
cancelAnimationFrame(animId)
clearTimeout(pauseTimer)
if (isAtBottom(scrollContainerRef.value)) scheduleBottomReturn(true)
pauseTimer = setTimeout(() => (isInteracting.value = false), 3000)
}
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
const isAtBottom = (el) => !!el && el.scrollHeight - el.clientHeight - el.scrollTop <= 8
const clearBottomReturn = () => {
clearTimeout(bottomReturnTimer)
bottomReturnTimer = null
}
const scheduleBottomReturn = (reset = false) => {
const el = scrollContainerRef.value
if (!el || !isAtBottom(el) || !isAutoScroll.value) return
if (bottomReturnTimer && !reset) return
clearBottomReturn()
bottomReturnTimer = setTimeout(() => {
bottomReturnTimer = null
const current = scrollContainerRef.value
if (!current || !isAtBottom(current) || !isAutoScroll.value || isDrawerOpen.value) return
if (isInteracting.value) {
scheduleBottomReturn(true)
return
}
smoothScrollTo(current, 0, switchDuration.value)
}, BOTTOM_RETURN_DELAY)
}
// 带时长的平滑滚动easeInOutQuadduration 由后台 pageSwitchDuration 控制
const smoothScrollTo = (el, target, duration) => {
cancelAnimationFrame(animId)
@@ -387,8 +361,11 @@ const goNextSection = () => {
if (!el) return
const top = el.scrollTop
const next = sectionEls().find((e) => e.offsetTop > top + 12)
// 循环逻辑:滚到最后一屏后回到第一屏继续轮播
smoothScrollTo(el, next ? next.offsetTop : 0, switchDuration.value)
if (next) {
smoothScrollTo(el, next.offsetTop, switchDuration.value)
return
}
scheduleBottomReturn()
}
const startAutoScroll = () => {
@@ -405,8 +382,12 @@ const startAutoScroll = () => {
freeTimer = setInterval(() => {
const el = scrollContainerRef.value
if (el && isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) {
if (isAtBottom(el)) {
scheduleBottomReturn()
return
}
el.scrollTop += 1.2
if (el.scrollTop >= el.scrollHeight - el.clientHeight - 5) el.scrollTop = 0
if (isAtBottom(el)) scheduleBottomReturn()
}
}, freeInterval.value)
}
@@ -417,10 +398,25 @@ const scheduleAutoScrollStart = () => {
scrollStartTimer = setTimeout(() => startAutoScroll(), firstScreenDuration.value)
}
const completeIntro = () => {
if (!showIntro.value) return
clearTimeout(introTimer)
showIntro.value = false
startAutoScroll()
}
const onIntroTransitionEnd = (e) => {
if (!introOpening.value) return
if (!e.target?.classList?.contains('curtain-door-right')) return
if (e.propertyName !== 'transform') return
completeIntro()
}
const toggleAutoScroll = () => {
isAutoScroll.value = !isAutoScroll.value
const el = scrollContainerRef.value
if (isAutoScroll.value && el && el.scrollTop >= el.scrollHeight - el.clientHeight - 10) el.scrollTop = 0
if (isAutoScroll.value && isAtBottom(el)) scheduleBottomReturn(true)
if (!isAutoScroll.value) clearBottomReturn()
}
// 入场揭幕:开门动画
@@ -428,24 +424,52 @@ const openInvitation = () => {
if (introOpening.value) return
clearTimeout(introTimer)
introOpening.value = true
introTimer = setTimeout(() => {
showIntro.value = false
scheduleAutoScrollStart()
}, 1000)
introTimer = setTimeout(() => completeIntro(), 1800)
// 点击揭幕是明确的用户手势:直接尝试播放背景音乐(最可靠的自动播放路径)
if (data.value.musicList.length) {
audio.play().then((ok) => {
if (ok) showMusicAuth.value = false
})
}
}
const onMusicAuthSkip = () => {
showMusicAuth.value = false
// 用户暂不开启:挂起首次交互解锁,后续任意点击仍可播放
audio.armAutoplay()
if (data.value.musicList.length) audio.play()
}
const selectTrack = (url) => { audio.setActive(url); showTrackList.value = false }
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 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 (isMobileDevice()) showMapOptions.value = true
else openMap('amap')
}
const handleSubmitRsvp = async () => {
if (!rsvpForm.name.trim()) return alert('请填写姓名')
try {
@@ -473,16 +497,17 @@ onMounted(async () => {
}
} catch (e) { /* 使用默认配置 */ }
if (data.value.introEnabled) showIntro.value = true
audio.setTracks(data.value.musicList, data.value.activeMusicUrl)
// 先静默尝试自动播放;被浏览器拦截(无用户手势)时,再弹出授权模态框
let autoMusicOk = true
if (data.value.musicList.length) {
const autoOk = await audio.attemptAutoplay()
if (!autoOk) showMusicAuth.value = true
autoMusicOk = await audio.attemptAutoplay()
}
if (data.value.introEnabled) {
showIntro.value = true
if (data.value.introEnabled && autoMusicOk) {
introTimer = setTimeout(() => openInvitation(), 3600)
} else {
} else if (!data.value.introEnabled) {
scheduleAutoScrollStart()
}
@@ -497,22 +522,65 @@ onUnmounted(() => {
clearTimeout(pauseTimer)
clearTimeout(introTimer)
clearTimeout(scrollStartTimer)
clearBottomReturn()
})
</script>
<style scoped>
.wrapper { font-family: "PingFang SC", sans-serif; }
.wrapper {
font-family: var(--font-sans);
font-variant-numeric: lining-nums;
text-rendering: geometricPrecision;
}
.wrapper .font-serif { font-family: var(--font-serif); }
.wrapper .font-sans { font-family: var(--font-sans); }
.control-btn {
width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; border-radius: 9999px;
backdrop-filter: blur(12px); border: 0.5px solid rgba(168, 140, 107, 0.3); color: #a88c6b; background: rgba(255,255,255,0.6);
}
.vertical-text { writing-mode: vertical-rl; letter-spacing: 0.4em; text-orientation: upright; }
.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-brush);
font-size: 25px;
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-brush);
color: #806f5c;
font-size: 16px;
line-height: 1.8;
font-weight: 700;
}
.page-section { min-height: 100vh; width: 100%; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; }
.no-scrollbar::-webkit-scrollbar { display: none; }
/* 花瓣形状由 SVG 决定,无需额外形状样式 */
.cover-kicker,
.schedule-kicker {
font-family: var(--font-display);
}
.cover-names {
font-family: var(--font-serif);
}
.cover-date {
font-family: var(--font-number);
}
.schedule-title {
font-family: var(--font-serif);
}
/* ---------- 入场揭幕(雅致象牙金) ---------- */
.curtain-root { position: fixed; inset: 0; z-index: 100; overflow: hidden; cursor: pointer; font-family: "PingFang SC", sans-serif; background: #fbf6ef; }
.curtain-root { position: fixed; inset: 0; z-index: 100; overflow: hidden; cursor: pointer; font-family: var(--font-sans); background: #fbf6ef; }
.curtain-bg {
position: absolute; inset: -24px; background-size: cover; background-position: center;
filter: blur(16px) brightness(0.7) saturate(1.05); transform: scale(1.12);
@@ -539,11 +607,20 @@ onUnmounted(() => {
.curtain-root.is-opening .curtain-emblem { opacity: 0; transform: scale(1.25); }
.emblem-inner { text-align: center; color: #5b4a36; padding: 10px; }
.emblem-floral { display: block; font-size: 22px; color: #b89a78; margin-bottom: 14px; }
.emblem-kicker { display: block; letter-spacing: 0.6em; font-size: 9px; color: #b89a78; text-transform: uppercase; margin-bottom: 18px; padding-left: 0.6em; }
.emblem-names { font-family: "Noto Serif SC", serif; font-size: 30px; letter-spacing: 0.18em; font-weight: 300; color: #4a3f30; }
.emblem-kicker { display: block; letter-spacing: 0.6em; font-size: 9px; color: #b89a78; text-transform: uppercase; margin-bottom: 18px; padding-left: 0.6em; font-family: var(--font-display); }
.emblem-names { font-family: var(--font-serif); font-size: 30px; letter-spacing: 0.18em; font-weight: 300; color: #4a3f30; }
.emblem-names .amp { font-style: italic; color: #b89a78; margin: 0 8px; }
.emblem-rule { width: 46px; height: 1px; background: linear-gradient(90deg, transparent, #b89a78, transparent); margin: 18px auto; }
.emblem-date { font-size: 11px; letter-spacing: 0.3em; color: #8a7458; }
.emblem-date { font-family: var(--font-number); font-size: 11px; letter-spacing: 0.3em; color: #8a7458; }
.emblem-invite-title {
margin-top: 24px; color: #4a3f30; font-family: var(--font-serif);
font-size: 18px; line-height: 1.35; letter-spacing: 0.14em; font-weight: 500;
}
.emblem-invite-copy {
max-width: 270px; margin: 12px auto 0; color: #6a5d4a;
font-family: var(--font-kai); font-size: 14px; line-height: 1.95;
letter-spacing: 0.08em;
}
.emblem-hint { margin-top: 26px; font-size: 11px; letter-spacing: 0.3em; color: #a08a66;
display: flex; align-items: center; justify-content: center; gap: 8px; }
.emblem-hint .dot { width: 6px; height: 6px; border-radius: 9999px; background: #b89a78; animation: hintPulse 1.6s infinite; }
@@ -571,7 +648,7 @@ onUnmounted(() => {
.ending-kicker {
display: flex; flex-direction: column; align-items: center; gap: 10px;
margin-bottom: 18px; color: #a88c6b; font-size: 10px; letter-spacing: 0.45em;
text-transform: uppercase; padding-left: 0.45em;
text-transform: uppercase; padding-left: 0.45em; font-family: var(--font-display);
}
.ending-kicker i {
display: block; width: 34px; height: 1px;
@@ -589,15 +666,15 @@ onUnmounted(() => {
}
.ending-title {
display: block; color: #a88c6b; font-size: 20px; line-height: 1.3;
letter-spacing: 0.28em; padding-left: 0.28em; font-weight: 300;
letter-spacing: 0.28em; padding-left: 0.28em; font-weight: 300; font-family: var(--font-serif);
}
.ending-names {
display: block; margin-top: 10px; color: #3A3A3A; font-size: 14px;
letter-spacing: 0.16em; font-family: "Noto Serif SC", serif;
letter-spacing: 0.16em; font-family: var(--font-display);
}
.ending-date {
display: block; margin-top: 8px; color: #8A8680; font-size: 10px;
letter-spacing: 0.28em; padding-left: 0.28em;
letter-spacing: 0.28em; padding-left: 0.28em; font-family: var(--font-number);
}
.ending-venue {
width: 100%; margin-bottom: 24px; padding: 14px 18px;
@@ -607,7 +684,7 @@ onUnmounted(() => {
}
.ending-hotel {
display: block; color: #3A3A3A; font-size: 13px; line-height: 1.6;
letter-spacing: 0.18em; font-weight: 500;
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;
@@ -615,6 +692,11 @@ onUnmounted(() => {
}
/* ---------- 回执 CTA 按钮 ---------- */
.ending-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.rsvp-cta {
position: relative; width: 100%; padding: 16px 0; border-radius: 9999px;
border: 1px solid #c9a978; background: rgba(255,255,255,0.6);
@@ -626,6 +708,59 @@ onUnmounted(() => {
.rsvp-cta:hover { background: #a88c6b; color: #fff; border-color: #a88c6b; box-shadow: 0 10px 26px rgba(184,154,120,0.28); }
.rsvp-cta-deco { font-size: 13px; opacity: .9; }
.rsvp-cta-line { width: 18px; height: 1px; background: currentColor; opacity: .6; }
.map-cta {
width: 100%; min-height: 58px; border-radius: 12px;
border: 1px solid rgba(168,140,107,0.28);
background:
linear-gradient(135deg, rgba(255,255,255,0.72), rgba(247,246,242,0.42)),
rgba(255,255,255,0.42);
color: #7a6244; cursor: pointer; padding: 9px 12px;
display: grid; grid-template-columns: 34px 1fr 16px; align-items: center; gap: 10px;
text-align: left; box-shadow: 0 8px 22px rgba(110,82,43,0.08);
transition: background .25s ease, border-color .25s ease, color .25s ease, box-shadow .25s ease;
}
.map-cta:hover {
background: rgba(255,255,255,0.75); border-color: rgba(168,140,107,0.58);
box-shadow: 0 12px 28px rgba(110,82,43,0.14);
}
.map-cta-icon {
width: 34px; height: 34px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
background: rgba(168,140,107,0.13); color: #a88c6b;
}
.map-cta-icon i { font-size: 15px; }
.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.16em; font-weight: 600;
}
.map-cta-copy small {
color: #8A8680; font-size: 9px; letter-spacing: 0.08em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.map-cta-arrow {
font-size: 11px; color: #b89a78;
}
/* ---------- 地图选择 ---------- */
.map-sheet {
width: min(100%, 360px); border-radius: 20px; padding: 14px;
background: rgba(255,255,255,0.96); border: 1px solid rgba(168,140,107,0.18);
box-shadow: 0 20px 60px rgba(0,0,0,0.24); backdrop-filter: blur(14px);
}
.map-sheet-title {
text-align: center; color: #8a704f; font-size: 12px; letter-spacing: 0.22em;
padding: 8px 0 12px;
}
.map-sheet button {
width: 100%; min-height: 44px; border: none; border-radius: 12px; margin-top: 8px;
background: #Fdfbf7; color: #4a3f30; font-size: 13px; letter-spacing: 0.12em;
cursor: pointer; transition: background .2s ease, color .2s ease;
display: flex; align-items: center; justify-content: center; gap: 9px;
}
.map-sheet button i { width: 15px; color: #a88c6b; }
.map-sheet button:hover { background: rgba(168,140,107,0.14); color: #8a704f; }
.map-sheet .map-sheet-cancel { color: #8A8680; background: transparent; }
/* ---------- 出席情况单选标签 ---------- */
.rsvp-tag {
@@ -636,17 +771,6 @@ onUnmounted(() => {
.rsvp-tag:hover { border-color: #a88c6b; }
.rsvp-tag.active { background: #a88c6b; border-color: #a88c6b; color: #fff; box-shadow: 0 4px 12px rgba(168,140,107,0.25); }
/* ---------- 音乐授权模态框 ---------- */
.music-auth-card { width: 240px; background: #fff; border-radius: 22px; padding: 28px 24px 22px; text-align: center;
box-shadow: 0 24px 60px rgba(0,0,0,0.28); border: 1px solid rgba(168,140,107,0.2); }
.music-auth-note { font-size: 26px; color: #a88c6b; margin-bottom: 12px; }
.music-auth-title { font-size: 16px; color: #4a3f30; letter-spacing: 0.2em; margin-bottom: 6px; }
.music-auth-sub { font-size: 11px; color: #8A8680; letter-spacing: 0.15em; margin-bottom: 20px; }
.music-auth-btn { width: 100%; padding: 11px 0; border-radius: 9999px; background: #a88c6b; color: #fff;
font-size: 12px; letter-spacing: 0.2em; cursor: pointer; border: none; transition: background .3s; }
.music-auth-btn:hover { background: #967b5c; }
.music-auth-skip { margin-top: 10px; background: none; border: none; color: #b0a99e; font-size: 11px; letter-spacing: 0.1em; cursor: pointer; }
/* ---------- 音乐曲目弹层 ---------- */
.music-wrap { position: relative; }
.track-pop {