Files
hunli/vite-tailwindcss/src/components/PhotoGallery.vue
2026-08-03 08:57:18 +08:00

732 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="gallery">
<!-- 标题区 -->
<div v-reveal="'up'" class="text-center mb-5 px-4">
<span class="gallery-title calligraphy text-xl text-[#a88c6b] tracking-[0.3em] font-bold block mb-2">{{ page.title }}</span>
<span class="gallery-subtitle text-[9px] text-[#8A8680] first-letter:uppercase lowercase 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>
<!-- 单图默认居中卡幅 / 可配置宽度铺满 -->
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
<div :class="singleFrameClass">
<img :src="displayImg1" class="preview-img" @click.stop="previewImage(originalImg1)" />
</div>
</div>
<!-- 错落双图固定 2 视口 30% / 50% -->
<div
v-else-if="page.type === 'overlap'"
ref="multiRootRef"
class="overlap-wrap relative w-full mt-2"
>
<div
class="absolute left-0 top-0 w-[72%] h-[78%] z-10 -rotate-2 overlap-slot"
:class="{ 'overlap-placeholder': shownCount < 1 }"
>
<div
v-if="shownCount >= 1"
v-reveal="{ variant: 'left', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayImg1"
class="preview-img overlap-img"
decoding="async"
@click.stop="previewImage(originalImg1)"
/>
</div>
</div>
<div
class="absolute right-0 bottom-0 w-[56%] h-[62%] z-20 rotate-3 overlap-slot"
:class="{ 'overlap-placeholder': shownCount < 2 }"
>
<div
v-if="shownCount >= 2"
v-reveal="{ variant: 'right', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayImg2"
class="preview-img overlap-img"
decoding="async"
@click.stop="previewImage(originalImg2)"
/>
</div>
</div>
</div>
<!-- 一大五小固定 6 槽占位 + 串行填内容大图就绪后再挂小图 -->
<div
v-else-if="page.type === 'one-large-five-small'"
ref="multiRootRef"
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20"
>
<div
class="col-span-2 row-span-2 relative olfs-slot"
:class="{ 'olfs-placeholder': shownCount < 1 }"
>
<div
v-if="shownCount >= 1"
v-reveal="{ variant: 'up', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
ref="olfsLargeImgRef"
:src="displayAt(0)"
class="preview-img olfs-img"
decoding="async"
@click.stop="previewImage(originalAt(0))"
/>
</div>
</div>
<div
v-for="i in [1, 2, 3, 4, 5]"
:key="i"
class="relative olfs-slot"
:class="{ 'olfs-placeholder': shownCount <= i }"
>
<div
v-if="shownCount > i"
v-reveal="{ variant: 'up', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayAt(i)"
class="preview-img olfs-img"
decoding="async"
@click.stop="previewImage(originalAt(i))"
/>
</div>
</div>
</div>
<!-- 九宫格近视口 IO 串行挂载双端不停滚 -->
<div v-else-if="page.type === 'nine-grid'" ref="multiRootRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
<div
v-for="(_, imgIdx) in (page.images || [])"
:key="imgIdx"
class="aspect-square relative nine-grid-slot"
:class="{ 'nine-grid-placeholder': imgIdx >= shownCount }"
>
<div
v-if="imgIdx < shownCount && nineGridAnimate"
v-reveal="getRevealConfig(imgIdx)"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayAt(imgIdx)"
class="nine-grid-img preview-img"
decoding="async"
@click.stop="previewImage(originalAt(imgIdx))"
/>
</div>
<div
v-else-if="imgIdx < shownCount"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayAt(imgIdx)"
class="nine-grid-img preview-img"
loading="eager"
decoding="async"
@click.stop="previewImage(originalAt(imgIdx))"
/>
</div>
</div>
</div>
<!-- 瀑布流固定槽位 + 串行挂载 -->
<div v-else-if="page.type === 'masonry'" ref="multiRootRef" class="g-masonry mt-6 px-1">
<div
v-for="(_, imgIdx) in (page.images || [])"
:key="imgIdx"
class="g-masonry-slot"
:class="{ 'g-masonry-placeholder': imgIdx >= shownCount }"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: 'up', immediate: true }"
class="g-masonry-item"
:class="frameClass"
>
<img :src="displayAt(imgIdx)" class="preview-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
</div>
<!-- 轮播滑动 -->
<div v-else-if="page.type === 'carousel'" class="relative mt-6">
<div ref="trackRef" class="g-carousel-track no-scrollbar" @scroll="onScroll">
<div v-for="(_, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
<img :src="displayAt(imgIdx)" class="preview-img" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
<button class="g-arrow g-prev" @click="goTo(index - 1)" aria-label="上一张"></button>
<button class="g-arrow g-next" @click="goTo(index + 1)" aria-label="下一张"></button>
<div class="g-dots">
<button v-for="(_, imgIdx) in page.images" :key="imgIdx"
class="g-dot" :class="{ active: imgIdx === index }" @click="goTo(imgIdx)"></button>
</div>
</div>
<!-- 拍立得固定槽位 + 串行挂载 -->
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
<div
v-for="(_, imgIdx) in (page.images || [])"
:key="imgIdx"
class="g-polaroid-slot"
:class="{ 'g-polaroid-placeholder': imgIdx >= shownCount }"
:style="imgIdx >= shownCount ? { transform: polaroidTransform(imgIdx) } : undefined"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: 'scale', immediate: true }"
class="g-polaroid-item"
:style="{ transform: polaroidTransform(imgIdx) }"
>
<img :src="displayAt(imgIdx)" class="preview-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
</div>
<div v-else class="text-center text-[#8A8680] text-[12px] mt-6">未识别的版式{{ page.type }}</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { borderClass } from '@/composables/borderStyles'
import { createSequentialMount } from '@/composables/useSequentialMount'
const NINE_STEP_MS = 300
const NINE_SETTLE_MS = 400
/** 瀑布/拍立得不停滚,步进略拉长减轻同时 decode */
const MULTI_STEP_MS = 360
const MULTI_SETTLE_MS = 320
/** 错落:视口露出比例分步挂载 + 两图最小间隔 */
const OVERLAP_RATIO_FIRST = 0.3
const OVERLAP_RATIO_SECOND = 0.5
const OVERLAP_MIN_GAP_MS = 520
const OLFS_STEP_MS = 300
const OLFS_SETTLE_MS = 320
const OLFS_DECODE_TIMEOUT_ANDROID_MS = 1200
const OLFS_DECODE_TIMEOUT_IOS_MS = 400
/** 仅用于 OLFS 是否跳过强制 decode */
const isIOSClient = (() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
const platform = navigator.platform || ''
if (/iPhone|iPad|iPod/i.test(ua)) return true
if (platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1) return true
return false
})()
/** 双端均不停滚:近视口 IO 串行挂载 */
const SEQUENTIAL_SELF_TYPES = new Set([
'masonry',
'polaroid',
'nine-grid',
'one-large-five-small',
])
const props = defineProps({
page: { type: Object, required: true },
/** 九宫格:开启串行 v-reveal关闭则一次展示 */
nineGridAnimate: { type: Boolean, default: false },
})
const emit = defineEmits(['preview-image', 'ready'])
const shownCount = ref(0)
const multiRootRef = ref(null)
const olfsLargeImgRef = ref(null)
let readyEmitted = false
let preloadStarted = false
let multiObserver = null
let multiStarted = false
let olfsToken = 0
let overlapToken = 0
let overlapFirstAt = 0
let overlapSecondTimer = null
const seq = createSequentialMount()
const frameClass = computed(() => borderClass(props.page.borderStyle))
/** 展示:缩放图优先;预览:原图优先 */
const pickDisplay = (original, resized) => resized || original || ''
const pickOriginal = (original, resized) => original || resized || ''
const displayImg1 = computed(() => pickDisplay(props.page?.img1, props.page?.img1Resized))
const displayImg2 = computed(() => pickDisplay(props.page?.img2, props.page?.img2Resized))
const originalImg1 = computed(() => pickOriginal(props.page?.img1, props.page?.img1Resized))
const originalImg2 = computed(() => pickOriginal(props.page?.img2, props.page?.img2Resized))
const displayAt = (i) => pickDisplay(props.page?.images?.[i], props.page?.imagesResized?.[i])
const originalAt = (i) => pickOriginal(props.page?.images?.[i], props.page?.imagesResized?.[i])
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const clearOverlapSecondTimer = () => {
clearTimeout(overlapSecondTimer)
overlapSecondTimer = null
}
/** iOS 跳过 img.decode手动滚动时强制 decode 易触发内存压力);仅等 load/complete */
const waitImgReady = (img, timeoutMs) => {
if (!img) return Promise.resolve()
const waitLoad = () => {
if (img.complete) return Promise.resolve()
return new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true })
img.addEventListener('error', resolve, { once: true })
})
}
const ready = isIOSClient
? waitLoad()
: (typeof img.decode === 'function' ? img.decode().catch(() => {}) : waitLoad())
return Promise.race([
ready,
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
])
}
const emitReady = () => {
if (readyEmitted) return
readyEmitted = true
emit('ready')
}
/** 关闭九宫格动画时提前暖缓存(优先展示用缩放图) */
const preloadNineGridImages = () => {
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
const urls = (props.page.images || [])
.map((_, i) => displayAt(i))
.filter(Boolean)
if (!urls.length) return
preloadStarted = true
for (const url of urls) {
const img = new Image()
img.decoding = 'async'
img.src = url
}
}
/** 注:曾尝试给安卓在 onMounted 阶段后台预热所有图,
* 实测反而更卡顿(下载集中在画廊接近视口时)。已移除。
* 若需预热,应只针对单张且错开更长时间间隔,且仅在用户即将到达时触发。 */
const runNineSequential = async () => {
const total = (props.page.images || []).length
const { cancelled } = await seq.run({
total,
stepMs: NINE_STEP_MS,
settleMs: NINE_SETTLE_MS,
setCount: (n) => { shownCount.value = n },
})
if (!cancelled) emitReady()
}
const showNineInstant = async () => {
shownCount.value = (props.page.images || []).length
await nextTick()
emitReady()
}
/** 一大五小:先大图并等就绪,再慢挂小图 */
const runOlfsSequential = async () => {
const token = ++olfsToken
const total = Math.min(6, (props.page.images || []).length)
if (!total) {
emitReady()
return
}
shownCount.value = 0
await nextTick()
if (token !== olfsToken) return
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return
shownCount.value = 1
await nextTick()
const timeout = isIOSClient ? OLFS_DECODE_TIMEOUT_IOS_MS : OLFS_DECODE_TIMEOUT_ANDROID_MS
await waitImgReady(olfsLargeImgRef.value, timeout)
if (token !== olfsToken) return
for (let n = 2; n <= total; n += 1) {
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return
shownCount.value = n
await nextTick()
}
if (token !== olfsToken) return
await sleep(OLFS_SETTLE_MS)
emitReady()
}
const runMultiSequential = async () => {
const total = (props.page.images || []).length
const { cancelled } = await seq.run({
total,
stepMs: MULTI_STEP_MS,
settleMs: MULTI_SETTLE_MS,
setCount: (n) => { shownCount.value = n },
})
if (!cancelled) emitReady()
}
const unbindMultiObserver = () => {
multiObserver?.disconnect()
multiObserver = null
}
const showOverlapSecond = (token) => {
if (token !== overlapToken) return
if (shownCount.value >= 2) return
shownCount.value = 2
clearOverlapSecondTimer()
unbindMultiObserver()
emitReady()
}
const scheduleOverlapSecond = (token) => {
if (token !== overlapToken || shownCount.value >= 2 || overlapSecondTimer) return
const elapsed = overlapFirstAt ? performance.now() - overlapFirstAt : 0
const wait = Math.max(0, OVERLAP_MIN_GAP_MS - elapsed)
overlapSecondTimer = setTimeout(() => showOverlapSecond(token), wait)
}
/** 错落双图:不停滚;露出 30% 挂第一张50% 挂第二张(并保证最小间隔) */
const bindOverlapObserver = async () => {
unbindMultiObserver()
clearOverlapSecondTimer()
const token = ++overlapToken
overlapFirstAt = 0
await nextTick()
const el = multiRootRef.value
if (!el || typeof IntersectionObserver === 'undefined') {
shownCount.value = 2
emitReady()
return
}
multiObserver = new IntersectionObserver(
(entries) => {
if (token !== overlapToken) return
for (const entry of entries) {
const ratio = entry.intersectionRatio
if (ratio >= OVERLAP_RATIO_FIRST && shownCount.value < 1) {
shownCount.value = 1
overlapFirstAt = performance.now()
}
if (ratio >= OVERLAP_RATIO_SECOND && shownCount.value >= 1) {
scheduleOverlapSecond(token)
}
}
},
{ threshold: [0, OVERLAP_RATIO_FIRST, OVERLAP_RATIO_SECOND, 0.75, 1] }
)
multiObserver.observe(el)
}
/** 按 page.type 调度对应的串行挂载函数 */
const runSequentialByType = async () => {
const type = props.page?.type
if (type === 'nine-grid') {
if (props.nineGridAnimate) await runNineSequential()
else await showNineInstant()
return
}
if (type === 'one-large-five-small') {
await runOlfsSequential()
return
}
if (type === 'masonry' || type === 'polaroid') {
await runMultiSequential()
}
}
const startSequentialMount = async () => {
if (multiStarted || readyEmitted) return
multiStarted = true
await runSequentialByType()
}
const bindMultiObserver = async () => {
unbindMultiObserver()
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) return
await nextTick()
const el = multiRootRef.value
if (!el || typeof IntersectionObserver === 'undefined') {
await startSequentialMount()
return
}
// 近视口再串行,避免页面加载即全量 decode不停滚
multiObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
multiObserver?.disconnect()
multiObserver = null
startSequentialMount()
break
}
},
{ threshold: [0, 0.1, 0.2, 0.3, 0.5], rootMargin: '0px 0px -10% 0px' }
)
multiObserver.observe(el)
}
const bindByType = async () => {
const type = props.page?.type
if (type === 'overlap') {
await bindOverlapObserver()
return
}
if (SEQUENTIAL_SELF_TYPES.has(type)) {
await bindMultiObserver()
}
}
watch(
() => [
props.page?.type,
props.page?.images,
props.page?.imagesResized,
props.page?.img1,
props.page?.img2,
props.page?.img1Resized,
props.page?.img2Resized,
],
async () => {
seq.cancel()
olfsToken += 1
overlapToken += 1
clearOverlapSecondTimer()
unbindMultiObserver()
multiStarted = false
shownCount.value = 0
readyEmitted = false
preloadStarted = false
overlapFirstAt = 0
if (props.page?.type === 'nine-grid' && !props.nineGridAnimate) {
preloadNineGridImages()
}
await bindByType()
},
{ deep: true, flush: 'post' }
)
watch(
() => props.nineGridAnimate,
() => {
if (!props.nineGridAnimate) preloadNineGridImages()
}
)
onMounted(() => {
if (!props.nineGridAnimate) preloadNineGridImages()
bindByType()
})
onUnmounted(() => {
seq.cancel()
olfsToken += 1
overlapToken += 1
clearOverlapSecondTimer()
unbindMultiObserver()
})
function getRevealConfig(imgIdx) {
const row = Math.floor(imgIdx / 3)
const col = imgIdx % 3
if (imgIdx === 4) {
return { variant: 'scale', delay: 0, immediate: true }
}
if (col === 1) {
return {
variant: row === 0 ? 'down' : 'up',
delay: 0,
immediate: true,
}
}
return {
variant: col === 0 ? 'left' : 'right',
delay: 0,
immediate: true,
}
}
const singleWrapClass = computed(() =>
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
)
const singleWrapStyle = computed(() => {
if (props.page.singleWidth === 'full') {
const bleed = { width: 'calc(100% + 48px)', marginLeft: '-24px', marginRight: '-24px' }
return props.page.singleKeepRatio ? bleed : { ...bleed, aspectRatio: '4 / 5' }
}
return { aspectRatio: '4 / 5' }
})
const singleFrameClass = computed(() =>
frameClass.value + (props.page.singleWidth === 'full' && props.page.singleKeepRatio ? ' gf-natural' : '')
)
const index = ref(0)
const trackRef = ref(null)
function goTo(i) {
const len = props.page.images?.length || 0
if (!len) return
index.value = (i + len) % len
const el = trackRef.value
if (el) el.scrollTo({ left: index.value * el.clientWidth, behavior: 'smooth' })
}
function onScroll() {
const el = trackRef.value
if (!el) return
const i = Math.round(el.scrollLeft / el.clientWidth)
if (i !== index.value) index.value = i
}
function previewImage(src) {
const url = typeof src === 'string' ? src.trim() : String(src?.value || src || '').trim()
if (url && url !== '[object Object]') emit('preview-image', url)
}
const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5]
const polaroidShift = [0, 10, -8, 6, -10, 8, 4, -6]
function polaroidTransform(i) {
const a = polaroidAngles[i % polaroidAngles.length]
const s = polaroidShift[i % polaroidShift.length]
return `rotate(${a}deg) translateY(${s}px)`
}
</script>
<style scoped>
.gallery { width: 100%; font-family: var(--font-sans); }
.preview-img { cursor: zoom-in; }
.gallery-title {
font-family: var(--font-calligraphy);
font-weight: 700;
}
.gallery-desc { font-family: var(--font-kai); }
.nine-grid-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nine-grid-slot {
background: transparent;
contain: layout paint;
}
.nine-grid-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.olfs-slot {
min-width: 0;
min-height: 0;
contain: layout paint;
}
.olfs-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
}
.olfs-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.overlap-wrap {
width: 100%;
max-width: 420px;
margin-inline: auto;
height: clamp(360px, 56vh, 520px);
padding: 12px 16px;
box-sizing: border-box;
}
.overlap-slot {
contain: layout paint;
}
.overlap-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.overlap-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.g-masonry { column-count: 2; column-gap: 10px; }
.g-masonry-slot {
break-inside: avoid;
margin-bottom: 10px;
contain: layout paint;
}
.g-masonry-placeholder {
visibility: hidden;
pointer-events: none;
min-height: 120px;
}
.g-masonry-item { border-radius: 6px; }
.g-carousel-track {
display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
border-radius: 10px; scrollbar-width: none;
}
.g-carousel-slide { flex: 0 0 100%; min-width: 100%; height: 52vh; scroll-snap-align: center; }
.g-carousel-slide img { width: 100%; height: 100%; object-fit: cover; }
.g-arrow {
position: absolute; top: 50%; transform: translateY(-50%);
width: 34px; height: 34px; border-radius: 9999px; border: none;
background: rgba(255,255,255,0.78); color: #a88c6b; font-size: 20px; line-height: 1;
display: flex; align-items: center; justify-content: center; cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.12); backdrop-filter: blur(6px);
}
.g-prev { left: 8px; }
.g-next { right: 8px; }
.g-dots { display: flex; justify-content: center; gap: 6px; margin-top: 10px; }
.g-dot { width: 7px; height: 7px; border-radius: 9999px; border: none; background: rgba(168,140,107,0.3); padding: 0; cursor: pointer; transition: all 0.3s; }
.g-dot.active { background: #a88c6b; width: 18px; border-radius: 9999px; }
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
.g-polaroid-slot {
width: 42%;
contain: layout paint;
}
.g-polaroid-placeholder {
visibility: hidden;
pointer-events: none;
min-height: 160px;
}
.g-polaroid-item {
width: 100%; background: #fff; padding: 8px 8px 24px; border-radius: 2px;
box-shadow: 0 6px 16px rgba(0,0,0,0.12); transition: transform 0.4s ease;
}
.g-polaroid-item:hover { z-index: 10; }
.g-polaroid-item img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; display: block; }
.no-scrollbar::-webkit-scrollbar { display: none; }
</style>