九宫格优化

This commit is contained in:
李琦
2026-08-01 16:01:05 +08:00
parent c3b4eeb4c4
commit a0db002ca6
3 changed files with 107 additions and 32 deletions

View File

@@ -4,12 +4,12 @@
<meta charset="UTF-8" />
<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="description" content="我们将在2026年9月4日举行婚礼,诚挚邀请你前来" />
<meta name="description" content="(公历2026年9月4日)岁在丙午,仲秋之吉,四日佳期。谨订良缘,敢邀台驾。" />
<meta property="og:type" content="website" />
<meta property="og:title" content="李琦&李逸烁的婚礼请柬" />
<meta property="og:description" content="我们将在2026年9月4日举行婚礼,诚挚邀请你前来" />
<meta property="og:description" content="(公历2026年9月4日)岁在丙午,仲秋之吉,四日佳期。谨订良缘,敢邀台驾。" />
<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>
</head>

View File

@@ -38,18 +38,19 @@
</div>
</div>
<!-- 九宫格透明占位撑布局activate 后按行分批挂载真图减轻主线程尖峰 -->
<!-- 九宫格外层仅透明占位decode 完成后再挂边框+ v-reveal 一起出现 -->
<div v-else-if="page.type === 'nine-grid'" ref="nineGridRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
<div
v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx"
class="aspect-square relative"
:class="[frameClass, imgIdx < nineShownCount ? '' : 'nine-grid-placeholder']"
class="aspect-square relative nine-grid-slot"
:class="{ 'nine-grid-placeholder': imgIdx >= nineShownCount }"
>
<div
v-if="imgIdx < nineShownCount"
v-reveal="getRevealConfig(imgIdx)"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="img"
@@ -135,30 +136,34 @@ const yieldToMain = () => new Promise((resolve) => {
}
})
/** 离屏预解码,完成后再抬 shownCount避免先露白底框 */
const preloadBatch = (urls) => Promise.race([
Promise.allSettled(
urls.map((url) => {
if (!url) return Promise.resolve()
const img = new Image()
img.decoding = 'async'
img.src = url
return decodeImg(img)
})
),
new Promise((resolve) => setTimeout(resolve, BATCH_DECODE_TIMEOUT_MS)),
])
const runBatchedReveal = async () => {
const images = props.page.images || []
const total = images.length
if (!total) return
for (let start = 0; start < total; start += BATCH_SIZE) {
nineShownCount.value = Math.min(total, start + BATCH_SIZE)
const end = Math.min(total, start + BATCH_SIZE)
await preloadBatch(images.slice(start, end))
await yieldToMain()
// decode 完再挂载:白卡纸/边框与图片一同走 reveal
nineShownCount.value = end
await nextTick()
await yieldToMain()
const root = nineGridRef.value
const end = nineShownCount.value
const imgs = root
? [...root.querySelectorAll('img[data-nine-idx]')].filter((el) => {
const i = Number(el.getAttribute('data-nine-idx'))
return i >= start && i < end
})
: []
await Promise.race([
Promise.allSettled(imgs.map(decodeImg)),
new Promise((resolve) => setTimeout(resolve, BATCH_DECODE_TIMEOUT_MS)),
])
if (end < total) {
await new Promise((resolve) => setTimeout(resolve, BATCH_GAP_MS))
await yieldToMain()
@@ -259,13 +264,16 @@ function getRevealConfig(imgIdx) {
backface-visibility: hidden;
}
.nine-grid-img:hover { transform: translateZ(0) scale(1.03); }
/* 透明占位:占满格子尺寸但不露灰底,避免 activate 时布局跳动 */
.nine-grid-slot {
background: transparent;
}
/* 透明占位:占满格子尺寸,不露白底/边框 */
.nine-grid-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent;
border: none;
box-shadow: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.overlap-wrap { height: clamp(320px, 54vh, 460px); }

View File

@@ -264,8 +264,12 @@
</div>
<!-- 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 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 v-if="isDrawerOpen" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm transition-opacity duration-500" @click="closeDrawer"></div>
<div
class="fixed bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col"
:class="{ 'pointer-events-none': !isDrawerOpen }"
:style="{ transform: isDrawerOpen ? 'translateY(0)' : 'translateY(100%)' }"
>
<div @click="closeDrawer" class="absolute top-6 right-6 p-2 text-[#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-center text-[10px] text-[#8A8680] mb-10 tracking-[0.4em]"> </span>
@@ -293,10 +297,18 @@
<span class="text-3xl text-[#a88c6b] mb-6 animate-pulse-heart"></span>
<span class="text-lg text-[#2c2c2c] mb-3 tracking-[0.2em] font-light">感恩您的祝福</span>
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light">回执已妥投期待与您共享喜悦</span>
<button class="map-cta mt-8 w-full max-w-[280px]" type="button" @click="openNavigation">
<span class="map-cta-icon"><i class="fa-solid fa-location-dot"></i></span>
<span class="map-cta-copy">
<b>查看酒店位置</b>
<small>{{ data.hotel }}</small>
</span>
<span class="map-cta-arrow">导航</span>
</button>
</div>
</div>
<div v-if="showMapOptions" class="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-title">选择导航方式</div>
<button type="button" @click="openMap('amap')"><i class="fa-solid fa-location-arrow"></i>高德地图</button>
@@ -306,6 +318,20 @@
</div>
</div>
<div v-if="showOpenInBrowser" class="open-browser-mask" @click="showOpenInBrowser = false">
<div class="open-browser-arrow" aria-hidden="true">
<svg viewBox="0 0 80 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40 100 C40 70 55 55 68 28" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M56 34 L70 22 L62 42" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
<div class="open-browser-copy">
<p class="open-browser-title">请点击右上角 ···</p>
<p class="open-browser-desc">选择在浏览器打开后再导航</p>
<p class="open-browser-hint">轻触屏幕关闭</p>
</div>
</div>
<a-image-preview-group :preview="albumPreviewOptions">
<div class="album-preview-registry" aria-hidden="true">
<a-image v-for="(src, idx) in albumPreviewImages" :key="`${src}-${idx}`" :src="src" />
@@ -339,6 +365,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 showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMapOptions = ref(false)
const showOpenInBrowser = ref(false)
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
const albumPreviewImages = ref([])
const nineGridActivate = reactive({})
@@ -486,7 +513,7 @@ const openImagePreview = async (src) => {
}
const effectsActive = computed(() =>
!isDrawerOpen.value && !previewVisible.value && !showMapOptions.value && !showIntro.value
!isDrawerOpen.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
)
const closeDrawer = () => { isDrawerOpen.value = false }
@@ -516,7 +543,7 @@ const clearBottomReturn = () => {
}
const scheduleBottomReturn = (reset = false) => {
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value) return
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) return
if (bottomReturnTimer && !reset) return
clearBottomReturn()
bottomReturnTimer = setTimeout(() => {
@@ -561,6 +588,7 @@ const canAutoAdvance = () =>
&& !isDrawerOpen.value
&& !previewVisible.value
&& !showMapOptions.value
&& !showOpenInBrowser.value
&& !nineGridHold.value
const beginNineGridGate = (pIdx) => {
@@ -665,6 +693,7 @@ 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 isWeChat = () => /MicroMessenger/i.test(navigator.userAgent)
const mapWebUrl = (provider) => {
const q = encodeURIComponent(mapKeyword.value || data.value.hotel || data.value.address || '')
@@ -697,6 +726,11 @@ const openMap = (provider = 'amap') => {
}
const openNavigation = () => {
if (isWeChat()) {
showMapOptions.value = false
showOpenInBrowser.value = true
return
}
if (isMobileDevice()) showMapOptions.value = true
else openMap('amap')
}
@@ -785,9 +819,9 @@ watch(
)
watch(freeScrollSignature, restartAutoScroll)
watch(
() => [isDrawerOpen.value, previewVisible.value, showMapOptions.value],
() => [isDrawerOpen.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
() => {
if (isDrawerOpen.value || previewVisible.value || showMapOptions.value) {
if (isDrawerOpen.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) {
cancelAnimationFrame(animId)
clearBottomReturn()
}
@@ -1360,6 +1394,39 @@ watch(
.map-sheet button:hover { background: rgba(168,140,107,0.14); color: #8a704f; }
.map-sheet .map-sheet-cancel { color: #8A8680; background: transparent; }
/* ---------- 微信内打开浏览器引导 ---------- */
.open-browser-mask {
position: fixed; inset: 0; z-index: 90;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
cursor: pointer;
}
.open-browser-arrow {
position: absolute; top: 8px; right: 18px;
width: 72px; height: 108px; color: #e8d5b5;
animation: open-browser-pulse 1.6s ease-in-out infinite;
}
.open-browser-arrow svg { width: 100%; height: 100%; display: block; }
.open-browser-copy {
position: absolute; top: 120px; left: 0; right: 0;
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 0 28px; text-align: center; pointer-events: none;
}
.open-browser-title {
color: #f3e7d3; font-size: 16px; letter-spacing: 0.28em;
font-family: var(--font-serif); font-weight: 500;
}
.open-browser-desc {
color: rgba(232, 213, 181, 0.88); font-size: 13px; letter-spacing: 0.14em; line-height: 1.7;
}
.open-browser-hint {
margin-top: 18px; color: rgba(168, 140, 107, 0.75); font-size: 10px; letter-spacing: 0.22em;
}
@keyframes open-browser-pulse {
0%, 100% { transform: translateY(0); opacity: 0.85; }
50% { transform: translateY(-6px); opacity: 1; }
}
/* ---------- 出席情况单选标签 ---------- */
.rsvp-tag {
flex: 1 1 auto; min-width: 72px; padding: 10px 12px; border-radius: 12px;