更新页面效果

This commit is contained in:
李琦
2026-08-01 08:18:59 +08:00
parent e8efc178a3
commit 4dcffcd500
11 changed files with 164 additions and 56 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/1774761336736_compressed_compressed.webp" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="我们将在2026年9月4日举行婚礼诚挚邀请你前来" /> <meta name="description" content="我们将在2026年9月4日举行婚礼诚挚邀请你前来" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -10,6 +10,7 @@
<meta property="og:description" content="我们将在2026年9月4日举行婚礼诚挚邀请你前来" /> <meta property="og:description" content="我们将在2026年9月4日举行婚礼诚挚邀请你前来" />
<meta name="twitter:title" content="李琦&李逸烁的婚礼请柬" /> <meta name="twitter:title" content="李琦&李逸烁的婚礼请柬" />
<meta name="twitter:description" content="我们将在2026年9月4日举行婚礼诚挚邀请你前来" /> <meta name="twitter:description" content="我们将在2026年9月4日举行婚礼诚挚邀请你前来" />
<meta property="og:image" content="https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/1774761336736_compressed_compressed.webp">
<title>李琦&李逸烁的婚礼请柬</title> <title>李琦&李逸烁的婚礼请柬</title>
</head> </head>
<body> <body>

View File

@@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@vueuse/head": "^2.0.0", "@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1", "ant-design-vue": "^4.1.1",
"aos": "^2.3.4",
"axios": "^1.6.2", "axios": "^1.6.2",
"lucide-vue-next": "^0.507.0", "lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
@@ -1756,6 +1757,17 @@
"vue": ">=3.2.0" "vue": ">=3.2.0"
} }
}, },
"node_modules/aos": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/aos/-/aos-2.3.4.tgz",
"integrity": "sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==",
"license": "MIT",
"dependencies": {
"classlist-polyfill": "^1.0.3",
"lodash.debounce": "^4.0.6",
"lodash.throttle": "^4.0.1"
}
},
"node_modules/array-tree-filter": { "node_modules/array-tree-filter": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz", "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
@@ -1815,6 +1827,12 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/classlist-polyfill": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz",
"integrity": "sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==",
"license": "Unlicense"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2538,6 +2556,18 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",

View File

@@ -9,18 +9,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"@ant-design-vue/pro-layout": "^3.2.5", "@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^6.7.2",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@vueuse/head": "^2.0.0", "@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1", "ant-design-vue": "^4.1.1",
"aos": "^2.3.4",
"axios": "^1.6.2", "axios": "^1.6.2",
"lucide-vue-next": "^0.507.0", "lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"swiper": "^11.0.5", "swiper": "^11.0.5",
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -26,6 +26,9 @@ importers:
ant-design-vue: ant-design-vue:
specifier: ^4.1.1 specifier: ^4.1.1
version: 4.2.6(vue@3.5.40) version: 4.2.6(vue@3.5.40)
aos:
specifier: ^2.3.4
version: 2.3.4
axios: axios:
specifier: ^1.6.2 specifier: ^1.6.2
version: 1.18.1(debug@4.4.3) version: 1.18.1(debug@4.4.3)
@@ -678,6 +681,9 @@ packages:
peerDependencies: peerDependencies:
vue: '>=3.2.0' vue: '>=3.2.0'
aos@2.3.4:
resolution: {integrity: sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==}
array-tree-filter@2.1.0: array-tree-filter@2.1.0:
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
@@ -698,6 +704,9 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
classlist-polyfill@1.2.0:
resolution: {integrity: sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -936,6 +945,12 @@ packages:
lodash-es@4.18.1: lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
lodash@4.18.1: lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -1619,6 +1634,12 @@ snapshots:
vue-types: 3.0.2(vue@3.5.40) vue-types: 3.0.2(vue@3.5.40)
warning: 4.0.3 warning: 4.0.3
aos@2.3.4:
dependencies:
classlist-polyfill: 1.2.0
lodash.debounce: 4.0.8
lodash.throttle: 4.1.1
array-tree-filter@2.1.0: {} array-tree-filter@2.1.0: {}
async-validator@4.2.5: {} async-validator@4.2.5: {}
@@ -1644,6 +1665,8 @@ snapshots:
dependencies: dependencies:
readdirp: 5.0.0 readdirp: 5.0.0
classlist-polyfill@1.2.0: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -1856,6 +1879,10 @@ snapshots:
lodash-es@4.18.1: {} lodash-es@4.18.1: {}
lodash.debounce@4.0.8: {}
lodash.throttle@4.1.1: {}
lodash@4.18.1: {} lodash@4.18.1: {}
loose-envify@1.4.0: loose-envify@1.4.0:

View File

@@ -1,5 +1,5 @@
allowBuilds: allowBuilds:
'@parcel/watcher': set this to true or false '@parcel/watcher': true
core-js: set this to true or false core-js: true
esbuild: set this to true or false esbuild: true
vue-demi: set this to true or false vue-demi: true

View File

@@ -167,11 +167,11 @@ onUnmounted(() => {
<style scoped> <style scoped>
.canvas-effects { .canvas-effects {
position: absolute; position: fixed;
inset: 0; inset: 0;
z-index: 1; z-index: 1;
width: 100%; width: 100%;
height: 100%; height: 100vh;
pointer-events: none; pointer-events: none;
} }
</style> </style>

View File

@@ -37,8 +37,13 @@
</div> </div>
<!-- 九宫格 --> <!-- 九宫格 -->
<div v-else-if="page.type === 'nine-grid'" class="grid grid-cols-3 gap-1.5 px-4 mt-6"> <div v-else-if="page.type === 'nine-grid'" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'up', delay: imgIdx * 24 }" <div v-for="(img, imgIdx) in page.images" :key="imgIdx"
data-aos="nine-grid-zoom"
:data-aos-delay="imgIdx * 200"
data-aos-duration="500"
data-aos-easing="ease-out-cubic"
data-aos-once="true"
class="nine-grid-reveal aspect-square relative" :class="frameClass"> class="nine-grid-reveal aspect-square relative" :class="frameClass">
<img :src="img" loading="lazy" decoding="async" class="nine-grid-img" /> <img :src="img" loading="lazy" decoding="async" class="nine-grid-img" />
</div> </div>
@@ -144,15 +149,19 @@ function polaroidTransform(i) {
.gallery-subtitle { font-family: var(--font-display); } .gallery-subtitle { font-family: var(--font-display); }
.gallery-desc { font-family: var(--font-kai); } .gallery-desc { font-family: var(--font-kai); }
.nine-grid-reveal.reveal { .nine-grid-reveal {
transform: translate3d(0, 12px, 0); opacity: 1;
transition: transform: translate3d(0, 0, 0);
opacity 420ms ease-out, }
transform 420ms cubic-bezier(0.22, 1, 0.36, 1); [data-aos='nine-grid-zoom'] {
opacity: 0;
transform: translate3d(0, 10px, 0) scale(0.94);
transition-property: opacity, transform;
will-change: opacity, transform; will-change: opacity, transform;
} }
.nine-grid-reveal.reveal--in { [data-aos='nine-grid-zoom'].aos-animate {
transform: translate3d(0, 0, 0); opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
.nine-grid-img { .nine-grid-img {
width: 100%; width: 100%;

View File

@@ -4,6 +4,7 @@ import Antd from 'ant-design-vue'
import './style.css' import './style.css'
import './styles/animations.css' import './styles/animations.css'
import './styles/frames.css' import './styles/frames.css'
import 'aos/dist/aos.css'
import App from './App.vue' import App from './App.vue'
import '@fortawesome/fontawesome-free/css/all.css' // 引入所有样式 import '@fortawesome/fontawesome-free/css/all.css' // 引入所有样式
// 注册路由 // 注册路由

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="wrapper relative w-full h-[100vh] bg-[#Fdfbf7] overflow-hidden text-[#2c2c2c]"> <div class="wrapper relative w-full min-h-[100vh] bg-[#Fdfbf7] overflow-x-hidden text-[#2c2c2c]">
<!-- 入场揭幕帘幕请柬封面 --> <!-- 入场揭幕帘幕请柬封面 -->
<div v-show="showIntro" class="curtain-root" :class="[{ 'is-opening': introOpening }, introExitClass]" @click="openInvitation"> <div v-show="showIntro" class="curtain-root" :class="[{ 'is-opening': introOpening }, introExitClass]" @click="openInvitation">
@@ -33,10 +33,10 @@
<CanvasEffects :petal-enabled="data.petalEnabled" :bubble-enabled="data.bubbleEnabled" /> <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> <div class="fixed top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
<!-- 右上角控制台 --> <!-- 右上角控制台 -->
<div class="absolute top-6 right-5 z-50 flex flex-col items-end gap-4"> <div class="fixed top-6 right-5 z-50 flex flex-col items-end gap-4">
<!-- 音乐控制 --> <!-- 音乐控制 -->
<div class="music-wrap"> <div class="music-wrap">
<div class="flex flex-col gap-3 items-end"> <div class="flex flex-col gap-3 items-end">
@@ -70,9 +70,8 @@
<!-- 主滚动区域注意不要加 scroll-smooth它会劫持 JS 滚动赋值导致卡顿 --> <!-- 主滚动区域注意不要加 scroll-smooth它会劫持 JS 滚动赋值导致卡顿 -->
<div ref="scrollContainerRef" <div ref="scrollContainerRef"
class="w-full h-full overflow-y-auto no-scrollbar relative" class="w-full min-h-[100vh] no-scrollbar relative"
:class="data.scrollMode === 'snap' ? 'snap-y snap-mandatory' : ''" @touchstart="handleUserInteraction" @touchmove="handleUserInteraction"
@scroll="handleScroll" @touchstart="handleUserInteraction" @touchmove="handleUserInteraction"
@wheel="handleUserInteraction" @mousedown="handleUserInteraction"> @wheel="handleUserInteraction" @mousedown="handleUserInteraction">
<!-- 1. 封面页 --> <!-- 1. 封面页 -->
@@ -251,8 +250,8 @@
</div> </div>
<!-- RSVP 抽屉 --> <!-- RSVP 抽屉 -->
<div v-if="isDrawerOpen" class="absolute inset-0 z-[60] bg-black/40 backdrop-blur-sm transition-opacity duration-500" @click="closeDrawer"></div> <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="absolute bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col" :style="{ transform: isDrawerOpen ? 'translateY(0)' : 'translateY(100%)' }"> <div class="fixed bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col" :style="{ transform: isDrawerOpen ? 'translateY(0)' : 'translateY(100%)' }">
<div @click="closeDrawer" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl"></div> <div @click="closeDrawer" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl"></div>
<span class="text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2">出席回执</span> <span class="text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2">出席回执</span>
<span class="text-center text-[10px] text-[#8A8680] mb-10 tracking-[0.4em]"> </span> <span class="text-center text-[10px] text-[#8A8680] mb-10 tracking-[0.4em]"> </span>
@@ -283,7 +282,7 @@
</div> </div>
</div> </div>
<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 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">
<div class="map-sheet-title">选择导航方式</div> <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('amap')"><i class="fa-solid fa-location-arrow"></i>高德地图</button>
@@ -297,11 +296,12 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { getConfig, submitRsvp } from '@/api/wedding' import { getConfig, submitRsvp } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio' import { useAudio } from '@/composables/useAudio'
import CanvasEffects from '@/components/CanvasEffects.vue' import CanvasEffects from '@/components/CanvasEffects.vue'
import PhotoGallery from '@/components/PhotoGallery.vue' import PhotoGallery from '@/components/PhotoGallery.vue'
import AOS from 'aos'
const DEFAULTS = { const DEFAULTS = {
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true, scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
@@ -326,12 +326,17 @@ const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label:
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
let freeScrollLastTs = 0 let freeScrollLastTs = 0
const BOTTOM_RETURN_DELAY = 30000 const BOTTOM_RETURN_DELAY = 30000
const FREE_SCROLL_BASE_STEP = 1.2
// 后台可配置的滚动参数(带默认值兜底,防止脏数据) // 后台可配置的滚动参数(带默认值兜底,防止脏数据)
const freeInterval = computed(() => { const freeInterval = computed(() => {
const v = Number(data.value.freeScrollInterval) const v = Number(data.value.freeScrollInterval)
return v >= 10 && v <= 1000 ? v : 50 return v >= 10 && v <= 1000 ? v : 50
}) })
const freeScrollSignature = computed(() => `${data.value.scrollMode}:${freeInterval.value}`)
const restartAutoScroll = () => {
if (snapTimer || rafId) startAutoScroll()
}
const switchDuration = computed(() => { const switchDuration = computed(() => {
const v = Number(data.value.pageSwitchDuration) const v = Number(data.value.pageSwitchDuration)
return v >= 0 && v <= 5000 ? v : 800 return v >= 0 && v <= 5000 ? v : 800
@@ -389,12 +394,12 @@ const formalPageHeightStyle = computed(() => sectionHeightStyle(data.value.forma
const pageHeightStyle = (page) => sectionHeightStyle(page.height) const pageHeightStyle = (page) => sectionHeightStyle(page.height)
const closeDrawer = () => { isDrawerOpen.value = false } const closeDrawer = () => { isDrawerOpen.value = false }
const pageScrollTop = () => window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
const pageScrollMax = () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
const handleScroll = () => { const handleScroll = () => {
const el = scrollContainerRef.value const max = pageScrollMax()
if (!el) return progress.value = max > 0 ? pageScrollTop() / max : 0
const max = el.scrollHeight - el.clientHeight if (isAtBottom()) scheduleBottomReturn()
progress.value = max > 0 ? el.scrollTop / max : 0
if (isAtBottom(el)) scheduleBottomReturn()
else clearBottomReturn() else clearBottomReturn()
} }
@@ -403,13 +408,13 @@ const handleUserInteraction = () => {
// 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动 // 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动
cancelAnimationFrame(animId) cancelAnimationFrame(animId)
clearTimeout(pauseTimer) clearTimeout(pauseTimer)
if (isAtBottom(scrollContainerRef.value)) scheduleBottomReturn(true) if (isAtBottom()) scheduleBottomReturn(true)
pauseTimer = setTimeout(() => (isInteracting.value = false), 3000) pauseTimer = setTimeout(() => (isInteracting.value = false), 3000)
} }
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || []) const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
const isAtBottom = (el) => !!el && el.scrollHeight - el.clientHeight - el.scrollTop <= 8 const isAtBottom = () => pageScrollMax() - pageScrollTop() <= 8
const clearBottomReturn = () => { const clearBottomReturn = () => {
clearTimeout(bottomReturnTimer) clearTimeout(bottomReturnTimer)
@@ -417,45 +422,41 @@ const clearBottomReturn = () => {
} }
const scheduleBottomReturn = (reset = false) => { const scheduleBottomReturn = (reset = false) => {
const el = scrollContainerRef.value if (!isAtBottom() || !isAutoScroll.value) return
if (!el || !isAtBottom(el) || !isAutoScroll.value) return
if (bottomReturnTimer && !reset) return if (bottomReturnTimer && !reset) return
clearBottomReturn() clearBottomReturn()
bottomReturnTimer = setTimeout(() => { bottomReturnTimer = setTimeout(() => {
bottomReturnTimer = null bottomReturnTimer = null
const current = scrollContainerRef.value if (!isAtBottom() || !isAutoScroll.value || isDrawerOpen.value) return
if (!current || !isAtBottom(current) || !isAutoScroll.value || isDrawerOpen.value) return
if (isInteracting.value) { if (isInteracting.value) {
scheduleBottomReturn(true) scheduleBottomReturn(true)
return return
} }
smoothScrollTo(current, 0, switchDuration.value) smoothScrollTo(0, switchDuration.value)
}, BOTTOM_RETURN_DELAY) }, BOTTOM_RETURN_DELAY)
} }
// 带时长的平滑滚动easeInOutQuadduration 由后台 pageSwitchDuration 控制 // 带时长的平滑滚动easeInOutQuadduration 由后台 pageSwitchDuration 控制
const smoothScrollTo = (el, target, duration) => { const smoothScrollTo = (target, duration) => {
cancelAnimationFrame(animId) cancelAnimationFrame(animId)
const start = el.scrollTop, dist = target - start const start = pageScrollTop(), dist = target - start
if (!dist) return if (!dist) return
if (duration <= 0) { el.scrollTop = target; return } if (duration <= 0) { window.scrollTo(0, target); return }
const t0 = performance.now() const t0 = performance.now()
const tick = (now) => { const tick = (now) => {
const p = Math.min(1, (now - t0) / duration) 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 e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2
el.scrollTop = start + dist * e window.scrollTo(0, start + dist * e)
if (p < 1) animId = requestAnimationFrame(tick) if (p < 1) animId = requestAnimationFrame(tick)
} }
animId = requestAnimationFrame(tick) animId = requestAnimationFrame(tick)
} }
const goNextSection = () => { const goNextSection = () => {
const el = scrollContainerRef.value const top = pageScrollTop()
if (!el) return
const top = el.scrollTop
const next = sectionEls().find((e) => e.offsetTop > top + 12) const next = sectionEls().find((e) => e.offsetTop > top + 12)
if (next) { if (next) {
smoothScrollTo(el, next.offsetTop, switchDuration.value) smoothScrollTo(next.offsetTop, switchDuration.value)
return return
} }
scheduleBottomReturn() scheduleBottomReturn()
@@ -466,24 +467,23 @@ const startAutoScroll = () => {
clearInterval(snapTimer) clearInterval(snapTimer)
clearInterval(freeTimer) clearInterval(freeTimer)
cancelAnimationFrame(rafId) cancelAnimationFrame(rafId)
rafId = null
if (data.value.scrollMode === 'snap') { if (data.value.scrollMode === 'snap') {
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection() if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection()
snapTimer = setInterval(() => { snapTimer = setInterval(() => {
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection() if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection()
}, 4500) }, 4500)
} else { } else {
// 自由滚动:按浏览器帧率匀速推进,速度仍由后台间隔控制(间隔越小越快)
freeScrollLastTs = 0 freeScrollLastTs = 0
const tickFreeScroll = (now) => { const tickFreeScroll = (now) => {
const dt = freeScrollLastTs ? Math.min(34, now - freeScrollLastTs) : 16.7 const dt = freeScrollLastTs ? Math.min(34, now - freeScrollLastTs) : 16.7
freeScrollLastTs = now freeScrollLastTs = now
const el = scrollContainerRef.value if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) {
if (el && isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) { if (isAtBottom()) {
if (isAtBottom(el)) {
scheduleBottomReturn() scheduleBottomReturn()
} else { } else {
el.scrollTop += dt * (1.2 / freeInterval.value) window.scrollTo(0, pageScrollTop() + FREE_SCROLL_BASE_STEP * (dt / freeInterval.value))
if (isAtBottom(el)) scheduleBottomReturn() if (isAtBottom()) scheduleBottomReturn()
} }
} }
rafId = requestAnimationFrame(tickFreeScroll) rafId = requestAnimationFrame(tickFreeScroll)
@@ -492,6 +492,17 @@ const startAutoScroll = () => {
} }
} }
const syncPageScrollMode = () => {
document.documentElement.classList.toggle('page-scroll-snap', data.value.scrollMode === 'snap')
}
const refreshAos = () => {
requestAnimationFrame(() => {
AOS.refreshHard()
setTimeout(() => AOS.refresh(), 250)
})
}
const scheduleAutoScrollStart = () => { const scheduleAutoScrollStart = () => {
clearTimeout(scrollStartTimer) clearTimeout(scrollStartTimer)
scrollStartTimer = setTimeout(() => startAutoScroll(), firstScreenDuration.value) scrollStartTimer = setTimeout(() => startAutoScroll(), firstScreenDuration.value)
@@ -506,8 +517,7 @@ const completeIntro = () => {
const toggleAutoScroll = () => { const toggleAutoScroll = () => {
isAutoScroll.value = !isAutoScroll.value isAutoScroll.value = !isAutoScroll.value
const el = scrollContainerRef.value if (isAutoScroll.value && isAtBottom()) scheduleBottomReturn(true)
if (isAutoScroll.value && isAtBottom(el)) scheduleBottomReturn(true)
if (!isAutoScroll.value) clearBottomReturn() if (!isAutoScroll.value) clearBottomReturn()
} }
@@ -572,6 +582,9 @@ const handleSubmitRsvp = async () => {
} }
onMounted(async () => { onMounted(async () => {
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
window.scrollTo(0, 0)
try { try {
const res = await getConfig() const res = await getConfig()
if (res.data && res.data !== '{}') { if (res.data && res.data !== '{}') {
@@ -592,6 +605,8 @@ onMounted(async () => {
} }
} catch (e) { /* 使用默认配置 */ } } catch (e) { /* 使用默认配置 */ }
syncPageScrollMode()
if (data.value.introEnabled) showIntro.value = true if (data.value.introEnabled) showIntro.value = true
audio.setTracks(data.value.musicList, data.value.activeMusicUrl) audio.setTracks(data.value.musicList, data.value.activeMusicUrl)
@@ -606,10 +621,25 @@ onMounted(async () => {
scheduleAutoScrollStart() scheduleAutoScrollStart()
} }
await nextTick()
AOS.init({
offset: 40,
duration: 500,
easing: 'ease-out-cubic',
once: true,
mirror: false,
throttleDelay: 120,
debounceDelay: 80,
disableMutationObserver: true,
})
refreshAos()
window.addEventListener('scroll', handleScroll, { passive: true })
handleScroll() handleScroll()
}) })
onUnmounted(() => { onUnmounted(() => {
document.documentElement.classList.remove('page-scroll-snap')
window.removeEventListener('scroll', handleScroll)
cancelAnimationFrame(rafId) cancelAnimationFrame(rafId)
cancelAnimationFrame(animId) cancelAnimationFrame(animId)
clearInterval(snapTimer) clearInterval(snapTimer)
@@ -619,6 +649,9 @@ onUnmounted(() => {
clearTimeout(scrollStartTimer) clearTimeout(scrollStartTimer)
clearBottomReturn() clearBottomReturn()
}) })
watch(() => data.value.scrollMode, syncPageScrollMode)
watch(freeScrollSignature, restartAutoScroll)
</script> </script>
<style scoped> <style scoped>
@@ -627,6 +660,12 @@ onUnmounted(() => {
font-variant-numeric: lining-nums; font-variant-numeric: lining-nums;
text-rendering: geometricPrecision; text-rendering: geometricPrecision;
} }
:global(html.page-scroll-snap) {
scroll-snap-type: y mandatory;
}
:global(html.page-scroll-snap body) {
scroll-snap-type: y mandatory;
}
.wrapper .font-serif { font-family: var(--font-serif); } .wrapper .font-serif { font-family: var(--font-serif); }
.wrapper .font-sans { font-family: var(--font-sans); } .wrapper .font-sans { font-family: var(--font-sans); }
.control-btn { .control-btn {