更新页面效果
This commit is contained in:
@@ -7,18 +7,18 @@
|
||||
<span class="text-[12px] text-[#6b6863] font-light leading-relaxed mx-auto max-w-[280px] tracking-widest block whitespace-pre-wrap">{{ page.desc }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 单图(满幅带留白卡纸) -->
|
||||
<!-- 单图(默认居中卡幅 / 可配置宽度铺满) -->
|
||||
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
|
||||
class="w-full max-w-[300px] mx-auto aspect-[4/5] mt-6">
|
||||
<div :class="frameClass"><img :src="page.img1" /></div>
|
||||
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
|
||||
<div :class="singleFrameClass"><img :src="page.img1" /></div>
|
||||
</div>
|
||||
|
||||
<!-- 错落双图 -->
|
||||
<div v-else-if="page.type === 'overlap'" class="relative w-full h-[50vh] mt-4">
|
||||
<div v-reveal="{ variant: 'left', delay: 100 }" class="absolute top-0 right-4 w-[70%] h-[65%] z-10" :class="frameClass">
|
||||
<!-- 错落双图(高度 clamp 封顶,图片贴容器边缘,任何页高下不溢出) -->
|
||||
<div v-else-if="page.type === 'overlap'" class="overlap-wrap relative w-full mt-2">
|
||||
<div v-reveal="{ variant: 'left', delay: 100 }" class="absolute top-0 right-0 w-[68%] h-[72%] z-10" :class="frameClass">
|
||||
<img :src="page.img1" />
|
||||
</div>
|
||||
<div v-reveal="{ variant: 'right', delay: 300 }" class="absolute bottom-4 left-4 w-[60%] h-[60%] z-20" :class="frameClass">
|
||||
<div v-reveal="{ variant: 'right', delay: 300 }" class="absolute bottom-0 left-0 w-[60%] h-[60%] z-20" :class="frameClass">
|
||||
<img :src="page.img2" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +89,23 @@ const props = defineProps({
|
||||
// 边框样式:前台按 page.borderStyle 套用全局 gf-* 类
|
||||
const frameClass = computed(() => borderClass(props.page.borderStyle))
|
||||
|
||||
/* ---------- 单图宽度模式 ---------- */
|
||||
// singleWidth: 'default'(居中卡幅) | 'full'(宽度铺满);singleKeepRatio: 铺满时是否保持原始宽高比
|
||||
const singleWrapClass = computed(() =>
|
||||
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
|
||||
)
|
||||
const singleWrapStyle = computed(() => {
|
||||
if (props.page.singleWidth === 'full') {
|
||||
// 铺满 + 保持原始比例 → 高度由图片自然撑开;铺满 + 不保持 → 固定 4:5 裁切
|
||||
return props.page.singleKeepRatio ? {} : { aspectRatio: '4 / 5' }
|
||||
}
|
||||
return { aspectRatio: '4 / 5' }
|
||||
})
|
||||
// 保持原始比例时 img 需要自然高度(gf-natural 覆盖 gf-frame 的 height:100%)
|
||||
const singleFrameClass = computed(() =>
|
||||
frameClass.value + (props.page.singleWidth === 'full' && props.page.singleKeepRatio ? ' gf-natural' : '')
|
||||
)
|
||||
|
||||
/* ---------- 轮播状态 ---------- */
|
||||
const index = ref(0)
|
||||
const trackRef = ref(null)
|
||||
@@ -120,6 +137,9 @@ function polaroidTransform(i) {
|
||||
<style scoped>
|
||||
.gallery { width: 100%; }
|
||||
|
||||
/* 错落双图:高度随视口缩放并封顶,保证标题+图片总高始终落在页高预算内 */
|
||||
.overlap-wrap { height: clamp(320px, 54vh, 460px); }
|
||||
|
||||
/* 瀑布流 */
|
||||
.g-masonry { column-count: 2; column-gap: 10px; }
|
||||
.g-masonry-item { break-inside: avoid; margin-bottom: 10px; border-radius: 6px; }
|
||||
|
||||
@@ -6,6 +6,11 @@ export const BORDER_STYLES = [
|
||||
{ value: 'rounded', label: '柔圆角' },
|
||||
{ value: 'double', label: '双线框' },
|
||||
{ value: 'gold', label: '古典金框' },
|
||||
{ value: 'shadow', label: '阴影浮起' },
|
||||
{ value: 'ink', label: '墨黑描边' },
|
||||
{ value: 'card', label: '悬浮卡片' },
|
||||
{ value: 'lace', label: '虚线文艺' },
|
||||
{ value: 'glow', label: '柔光金晕' },
|
||||
]
|
||||
|
||||
export function borderClass(style) {
|
||||
|
||||
@@ -26,44 +26,76 @@
|
||||
}
|
||||
|
||||
/* ---------- 飘落花瓣(优雅:柔和渐变 + 轻微摇曳) ---------- */
|
||||
/* 性能约定:摇曳动画一律作用在内层元素的 transform 上,
|
||||
外层只跑位移动画 —— 全程合成层处理,不触发布局。 */
|
||||
@keyframes petalFall {
|
||||
0% { transform: translate3d(0, -12vh, 0) rotate(0deg); opacity: 0; }
|
||||
10% { opacity: 0.85; }
|
||||
90% { opacity: 0.7; }
|
||||
10% { opacity: 0.8; }
|
||||
90% { opacity: 0.65; }
|
||||
100% { transform: translate3d(6vw, 112vh, 0) rotate(360deg); opacity: 0; }
|
||||
}
|
||||
@keyframes petalDrift {
|
||||
0%, 100% { margin-left: -14px; }
|
||||
50% { margin-left: 14px; }
|
||||
0%, 100% { transform: translateX(-12px) rotate(-7deg); }
|
||||
50% { transform: translateX(12px) rotate(7deg); }
|
||||
}
|
||||
.petal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
animation:
|
||||
petalFall var(--p-dur, 12s) linear infinite,
|
||||
petalDrift var(--p-sway, 6s) ease-in-out infinite alternate;
|
||||
animation: petalFall var(--p-dur, 12s) linear infinite;
|
||||
}
|
||||
.petal-inner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
animation: petalDrift var(--p-sway, 6s) ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* ---------- 漂浮爱心(全屏自然上升,随机分布) ---------- */
|
||||
/* ---------- 漂浮爱心(全屏自然上升,三层景深) ---------- */
|
||||
@keyframes heartFloat {
|
||||
0% { transform: translateY(20px) translateX(0) scale(0.7); opacity: 0; }
|
||||
12% { opacity: 0.8; }
|
||||
85% { opacity: 0.6; }
|
||||
100% { transform: translateY(-118vh) translateX(24px) scale(1.1); opacity: 0; }
|
||||
0% { transform: translate3d(0, 20px, 0) scale(0.7); opacity: 0; }
|
||||
12% { opacity: var(--h-opacity, 0.7); }
|
||||
85% { opacity: calc(var(--h-opacity, 0.7) * 0.75); }
|
||||
100% { transform: translate3d(24px, -118vh, 0) scale(1.08); opacity: 0; }
|
||||
}
|
||||
@keyframes heartSway {
|
||||
0%, 100% { margin-left: -16px; }
|
||||
50% { margin-left: 16px; }
|
||||
0%, 100% { transform: translateX(-14px) scale(0.97); }
|
||||
50% { transform: translateX(14px) scale(1.03); }
|
||||
}
|
||||
.heart-bubble {
|
||||
position: absolute;
|
||||
will-change: transform, opacity;
|
||||
animation:
|
||||
heartFloat var(--float-duration, 18s) linear infinite,
|
||||
heartSway var(--sway-duration, 5s) ease-in-out infinite alternate;
|
||||
animation: heartFloat var(--float-duration, 18s) linear infinite;
|
||||
}
|
||||
.heart-inner {
|
||||
display: inline-block;
|
||||
will-change: transform;
|
||||
animation: heartSway var(--sway-duration, 5s) ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* ---------- 背景柔光层(淡金/淡粉光斑,缓慢漂移,不喧宾夺主) ---------- */
|
||||
.bg-glow { position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
|
||||
.bg-glow i { position: absolute; border-radius: 50%; filter: blur(46px); will-change: transform; }
|
||||
.bg-glow .g1 {
|
||||
width: 300px; height: 300px; top: -90px; right: -70px;
|
||||
background: radial-gradient(circle, rgba(233, 206, 168, 0.5), transparent 68%);
|
||||
animation: glowDrift1 17s ease-in-out infinite alternate;
|
||||
}
|
||||
.bg-glow .g2 {
|
||||
width: 260px; height: 260px; bottom: -80px; left: -60px;
|
||||
background: radial-gradient(circle, rgba(244, 194, 194, 0.42), transparent 68%);
|
||||
animation: glowDrift2 21s ease-in-out infinite alternate;
|
||||
}
|
||||
.bg-glow .g3 {
|
||||
width: 210px; height: 210px; top: 38%; left: 58%;
|
||||
background: radial-gradient(circle, rgba(212, 175, 55, 0.16), transparent 70%);
|
||||
animation: glowDrift3 24s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes glowDrift1 { to { transform: translate(-34px, 26px) scale(1.08); } }
|
||||
@keyframes glowDrift2 { to { transform: translate(30px, -24px) scale(1.06); } }
|
||||
@keyframes glowDrift3 { to { transform: translate(-26px, 20px) scale(1.12); } }
|
||||
|
||||
/* ---------- 心跳脉冲 ---------- */
|
||||
@keyframes pulseHeart {
|
||||
|
||||
@@ -69,3 +69,57 @@
|
||||
box-shadow: 0 0 0 4px #fff, 0 0 0 5px #e7d6bb, 0 8px 22px rgba(120, 90, 60, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 阴影浮起(无边框,大柔影悬浮感) */
|
||||
.gf-shadow {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 44px rgba(96, 72, 50, 0.2), 0 4px 12px rgba(96, 72, 50, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 墨黑描边(简约深色细框) */
|
||||
.gf-ink {
|
||||
padding: 6px;
|
||||
border: 1.5px solid #3a3733;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 悬浮卡片(厚白边 + 大圆角 + 层叠影) */
|
||||
.gf-card {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(168, 140, 107, 0.18);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 6px rgba(120, 90, 60, 0.08), 0 16px 36px rgba(120, 90, 60, 0.16);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 虚线文艺(dashed 描边 + 内白边) */
|
||||
.gf-lace {
|
||||
padding: 7px;
|
||||
border: 1.5px dashed #b89a78;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 12px rgba(120, 90, 60, 0.08);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 柔光金晕(浅金边 + 外发光) */
|
||||
.gf-glow {
|
||||
padding: 5px;
|
||||
border: 1px solid #e7d3ae;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 3px rgba(231, 211, 174, 0.35), 0 0 26px rgba(201, 169, 120, 0.45), 0 6px 16px rgba(120, 90, 60, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 辅助:图片保持原始宽高比(单图铺满+保比例时使用,覆盖 gf-frame 的 height:100%) */
|
||||
.gf-natural {
|
||||
height: auto !important;
|
||||
}
|
||||
.gf-natural > img {
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,22 @@
|
||||
</a-radio-group>
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">整页模式会带来更有仪式感的逐屏切换效果。</div>
|
||||
</a-form-item>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<a-form-item label="自由滚动速度(滚动间隔)">
|
||||
<div class="flex gap-3 items-center">
|
||||
<a-slider v-model:value="cfg.freeScrollInterval" :min="10" :max="500" class="flex-1" />
|
||||
<a-input-number v-model:value="cfg.freeScrollInterval" :min="10" :max="500" addon-after="ms" class="w-[120px]" />
|
||||
</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">间隔越小滚动越快,默认 50ms。仅在「自由滚动」模式下生效。</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="页面切换速度(动画时长)">
|
||||
<div class="flex gap-3 items-center">
|
||||
<a-slider v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" class="flex-1" />
|
||||
<a-input-number v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" addon-after="ms" class="w-[120px]" />
|
||||
</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">整页/分屏模式下自动翻页的动画时长,默认 800ms。</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a-form-item label="飘落花瓣">
|
||||
<a-switch v-model:checked="cfg.petalEnabled" />
|
||||
@@ -185,6 +201,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 单图版式的宽度设置(逐页配置) -->
|
||||
<div v-if="page.type==='single'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start">
|
||||
<a-form-item label="图片宽度">
|
||||
<a-radio-group v-model:value="page.singleWidth">
|
||||
<a-radio value="default">默认(居中卡幅)</a-radio>
|
||||
<a-radio value="full">宽度铺满</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="page.singleWidth==='full'" label="保持原始宽高比">
|
||||
<a-switch v-model:checked="page.singleKeepRatio" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">开启:按图片原始比例显示;关闭:固定 4:5 比例裁切铺满。</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<a-form-item label="小标题 (英文)">
|
||||
<a-input v-model:value="page.subtitle" />
|
||||
@@ -295,6 +324,7 @@ function defaultConfig() {
|
||||
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
|
||||
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
freeScrollInterval: 50, pageSwitchDuration: 800,
|
||||
photoPages: [
|
||||
{ type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大,还是遇见了你。\n于千万人之中,没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80', height: '100vh', borderStyle: 'mat' },
|
||||
{ type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初,岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80', height: '100vh', borderStyle: 'mat' },
|
||||
@@ -354,6 +384,9 @@ function ensurePageShape(p) {
|
||||
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
|
||||
p.height = p.height || '100vh'
|
||||
p.borderStyle = p.borderStyle || 'mat'
|
||||
// 单图宽度设置(仅 single 版式使用,逐页配置)
|
||||
p.singleWidth = p.singleWidth === 'full' ? 'full' : 'default'
|
||||
if (typeof p.singleKeepRatio !== 'boolean') p.singleKeepRatio = true
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -482,6 +515,9 @@ onMounted(async () => {
|
||||
}
|
||||
Object.assign(cfg, loaded)
|
||||
cfg.photoPages = (cfg.photoPages || []).map(ensurePageShape)
|
||||
// 数值型参数兜底(兼容旧配置)
|
||||
cfg.freeScrollInterval = Number(cfg.freeScrollInterval) || 50
|
||||
cfg.pageSwitchDuration = Number(cfg.pageSwitchDuration) || 800
|
||||
}
|
||||
} catch (e) { /* 使用默认配置 */ }
|
||||
if (adminStore.isAdmin) loadRsvp()
|
||||
|
||||
@@ -19,22 +19,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 飘落花瓣层 -->
|
||||
<!-- 背景柔光层(淡金/淡粉光斑,缓慢漂移) -->
|
||||
<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, '--p-sway': p.sway }">
|
||||
<svg viewBox="0 0 100 100" class="w-full h-full" :style="{ filter: 'drop-shadow(0 2px 3px rgba(150,110,90,0.12))' }">
|
||||
: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', opacity: b.opacity, animationDelay: b.delay, '--float-duration': b.floatDuration, '--sway-duration': b.swayDuration }">♥</div>
|
||||
: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>
|
||||
|
||||
<!-- 顶部滚动进度条 -->
|
||||
@@ -72,9 +77,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主滚动区域 -->
|
||||
<!-- 主滚动区域(注意:不要加 scroll-smooth,它会劫持 JS 滚动赋值导致卡顿) -->
|
||||
<div ref="scrollContainerRef"
|
||||
class="w-full h-full overflow-y-auto no-scrollbar relative scroll-smooth"
|
||||
class="w-full h-full overflow-y-auto no-scrollbar relative"
|
||||
:class="data.scrollMode === 'snap' ? 'snap-y snap-mandatory' : ''"
|
||||
@scroll="handleScroll" @touchstart="handleUserInteraction" @touchmove="handleUserInteraction"
|
||||
@wheel="handleUserInteraction" @mousedown="handleUserInteraction">
|
||||
@@ -223,6 +228,8 @@ import PhotoGallery from '@/components/PhotoGallery.vue'
|
||||
const DEFAULTS = {
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
musicList: [], activeMusicUrl: '',
|
||||
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
|
||||
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
|
||||
}
|
||||
|
||||
const data = ref({
|
||||
@@ -247,22 +254,59 @@ const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(fal
|
||||
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
|
||||
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null
|
||||
|
||||
const loveColors = ['#a88c6b', '#e8a5b2', '#f4c2c2', '#d4af37']
|
||||
const petalColors = ['#f7d9e0', '#fbeee3', '#f3c6d3', '#efd9c4', '#f9e7d6']
|
||||
const bubbles = Array.from({ length: 15 }).map((_, i) => ({
|
||||
id: i, left: Math.random() * 100 + '%', top: Math.random() * 100 + '%',
|
||||
color: loveColors[i % 4], size: 13 + Math.random() * 10, opacity: 0.25 + Math.random() * 0.15,
|
||||
floatDuration: (14 + Math.random() * 10) + 's', swayDuration: (4 + Math.random() * 2) + 's',
|
||||
delay: '-' + (Math.random() * 18).toFixed(1) + 's',
|
||||
}))
|
||||
const petals = Array.from({ length: 18 }).map((_, i) => ({
|
||||
id: i, left: Math.random() * 100 + '%', size: 10 + Math.random() * 14,
|
||||
opacity: 0.4 + Math.random() * 0.4, color: petalColors[i % petalColors.length],
|
||||
duration: (9 + Math.random() * 8) + 's', delay: (Math.random() * 10) + 's',
|
||||
sway: (4 + Math.random() * 4) + 's',
|
||||
}))
|
||||
// 后台可配置的滚动参数(带默认值兜底,防止脏数据)
|
||||
const freeInterval = computed(() => {
|
||||
const v = Number(data.value.freeScrollInterval)
|
||||
return v >= 10 && v <= 1000 ? v : 50
|
||||
})
|
||||
const switchDuration = computed(() => {
|
||||
const v = Number(data.value.pageSwitchDuration)
|
||||
return v >= 0 && v <= 5000 ? v : 800
|
||||
})
|
||||
|
||||
// 装饰元素配色(柔化后的低饱和金粉系,与主题协调不抢眼)
|
||||
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})` }))
|
||||
|
||||
@@ -285,18 +329,37 @@ const handleScroll = () => {
|
||||
|
||||
const handleUserInteraction = () => {
|
||||
isInteracting.value = true
|
||||
// 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动
|
||||
cancelAnimationFrame(animId)
|
||||
clearTimeout(pauseTimer)
|
||||
pauseTimer = setTimeout(() => (isInteracting.value = false), 3000)
|
||||
}
|
||||
|
||||
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
|
||||
|
||||
// 带时长的平滑滚动(easeInOutQuad),duration 由后台 pageSwitchDuration 控制
|
||||
const smoothScrollTo = (el, target, duration) => {
|
||||
cancelAnimationFrame(animId)
|
||||
const start = el.scrollTop, dist = target - start
|
||||
if (!dist) return
|
||||
if (duration <= 0) { el.scrollTop = target; return }
|
||||
const t0 = performance.now()
|
||||
const tick = (now) => {
|
||||
const p = Math.min(1, (now - t0) / duration)
|
||||
const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2
|
||||
el.scrollTop = start + dist * e
|
||||
if (p < 1) animId = requestAnimationFrame(tick)
|
||||
}
|
||||
animId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const goNextSection = () => {
|
||||
const el = scrollContainerRef.value
|
||||
if (!el) return
|
||||
const top = el.scrollTop
|
||||
const next = sectionEls().find((e) => e.offsetTop > top + 12)
|
||||
if (next) el.scrollTo({ top: next.offsetTop, behavior: 'smooth' })
|
||||
else isAutoScroll.value = false
|
||||
// 循环逻辑:滚到最后一屏后回到第一屏继续轮播
|
||||
smoothScrollTo(el, next ? next.offsetTop : 0, switchDuration.value)
|
||||
}
|
||||
|
||||
const startAutoScroll = () => {
|
||||
@@ -305,15 +368,14 @@ const startAutoScroll = () => {
|
||||
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection()
|
||||
}, 4500)
|
||||
} else {
|
||||
const step = () => {
|
||||
// 自由滚动:setInterval 按后台配置的间隔匀速推进(间隔越小越快)
|
||||
freeTimer = setInterval(() => {
|
||||
const el = scrollContainerRef.value
|
||||
if (el && isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) {
|
||||
el.scrollTop += 0.8
|
||||
if (el.scrollTop >= el.scrollHeight - el.clientHeight - 5) isAutoScroll.value = false
|
||||
el.scrollTop += 1.2
|
||||
if (el.scrollTop >= el.scrollHeight - el.clientHeight - 5) el.scrollTop = 0
|
||||
}
|
||||
rafId = requestAnimationFrame(step)
|
||||
}
|
||||
step()
|
||||
}, freeInterval.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +440,9 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafId)
|
||||
cancelAnimationFrame(animId)
|
||||
clearInterval(snapTimer)
|
||||
clearInterval(freeTimer)
|
||||
clearTimeout(pauseTimer)
|
||||
clearTimeout(introTimer)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user