Files
hunli/vite-tailwindcss/src/components/PhotoGallery.vue
2026-08-02 21:41:43 +08:00

611 lines
20 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="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" />
</div>
</div>
<!-- 错落双图 -->
<div v-else-if="page.type === 'overlap'" class="overlap-wrap relative w-full mt-2">
<div v-reveal="{ variant: 'left', delay: 100 }"
class="absolute left-0 top-0 w-[72%] h-[78%] z-10 -rotate-2" :class="frameClass">
<img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" />
</div>
<div v-reveal="{ variant: 'right', delay: 300 }"
class="absolute right-0 bottom-0 w-[56%] h-[62%] z-20 rotate-3" :class="frameClass">
<img :src="page.img2" class="preview-img" @click.stop="previewImage(page.img2)" />
</div>
</div>
<!-- 一大五小固定 6 槽占位 + 串行填内容大图 decode 后再挂小图 -->
<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="page.images[0]"
class="preview-img olfs-img"
decoding="async"
@click.stop="previewImage(page.images[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="page.images[i]"
class="preview-img olfs-img"
decoding="async"
@click.stop="previewImage(page.images[i])"
/>
</div>
</div>
</div>
<!-- 九宫格串行挂载 + immediate由父级 activate 触发iOS 自观测 -->
<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="(img, 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="img"
class="nine-grid-img preview-img"
decoding="async"
@click.stop="previewImage(img)"
/>
</div>
<div
v-else-if="imgIdx < shownCount"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="img"
class="nine-grid-img preview-img"
loading="eager"
decoding="async"
@click.stop="previewImage(img)"
/>
</div>
</div>
</div>
<!-- 瀑布流串行挂载 + immediate -->
<div v-else-if="page.type === 'masonry'" ref="multiRootRef" class="g-masonry mt-6 px-1">
<div
v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: 'up', immediate: true }"
class="g-masonry-item"
:class="frameClass"
>
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
</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="(img, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
</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="(img, imgIdx) in page.images" :key="imgIdx"
class="g-dot" :class="{ active: imgIdx === index }" @click="goTo(imgIdx)"></button>
</div>
</div>
<!-- 拍立得串行挂载 + immediate -->
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
<div
v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: 'scale', immediate: true }"
class="g-polaroid-item"
:style="{ transform: polaroidTransform(imgIdx) }"
>
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
</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
const MULTI_STEP_MS = 220
const MULTI_SETTLE_MS = 300
const OLFS_STEP_MS = 300
const OLFS_SETTLE_MS = 320
const OLFS_DECODE_TIMEOUT_MS = 1200
/** 瀑布流/拍立得仍自观测;九宫格/一大五小由父级 activate+停滚驱动
* iOS 性能足够,跳过停滚,这两种类型也走自观测(进视口自动挂载) */
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
})()
const SEQUENTIAL_SELF_TYPES = new Set(
isIOSClient ? ['masonry', 'polaroid', 'nine-grid', 'one-large-five-small'] : ['masonry', 'polaroid']
)
const HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small'])
const props = defineProps({
page: { type: Object, required: true },
/** 九宫格/一大五小:父级停滚后设为 true 才串行挂载 */
activate: { type: Boolean, default: false },
/** 九宫格:开启串行 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
const seq = createSequentialMount()
const frameClass = computed(() => borderClass(props.page.borderStyle))
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const waitImgReady = (img, timeoutMs) => {
if (!img) return Promise.resolve()
const decode = () => {
if (typeof img.decode === 'function') return img.decode().catch(() => {})
if (img.complete) return Promise.resolve()
return new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true })
img.addEventListener('error', resolve, { once: true })
})
}
return Promise.race([
decode(),
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 || []).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
console.log('[PG] runNineSequential start, total=', total)
const { cancelled } = await seq.run({
total,
stepMs: NINE_STEP_MS,
settleMs: NINE_SETTLE_MS,
setCount: (n) => { console.log('[PG] nine setCount', n); shownCount.value = n },
})
console.log('[PG] runNineSequential end, cancelled=', cancelled)
if (!cancelled) emitReady()
}
const showNineInstant = async () => {
console.log('[PG] showNineInstant, count=', (props.page.images || []).length)
shownCount.value = (props.page.images || []).length
await nextTick()
emitReady()
}
/** 一大五小:先大图并等 decode再慢挂小图 */
const runOlfsSequential = async () => {
const token = ++olfsToken
const total = Math.min(6, (props.page.images || []).length)
console.log('[PG] runOlfsSequential start, total=', total, 'token=', token)
if (!total) {
emitReady()
return
}
shownCount.value = 0
await nextTick()
if (token !== olfsToken) { console.log('[PG] olfs cancelled at start'); return }
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) { console.log('[PG] olfs cancelled before 1'); return }
shownCount.value = 1
await nextTick()
console.log('[PG] olfs shownCount=1, olfsLargeImgRef=', !!olfsLargeImgRef.value)
await waitImgReady(olfsLargeImgRef.value, OLFS_DECODE_TIMEOUT_MS)
if (token !== olfsToken) { console.log('[PG] olfs cancelled after large'); return }
for (let n = 2; n <= total; n += 1) {
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) { console.log('[PG] olfs cancelled at n=', n); return }
shownCount.value = n
console.log('[PG] olfs shownCount=', n)
await nextTick()
}
if (token !== olfsToken) return
await sleep(OLFS_SETTLE_MS)
console.log('[PG] olfs done')
emitReady()
}
const runMultiSequential = async () => {
const type = props.page?.type
if (!SEQUENTIAL_SELF_TYPES.has(type) || multiStarted) return
multiStarted = true
const total = (props.page.images || []).length
await seq.run({
total,
stepMs: MULTI_STEP_MS,
settleMs: MULTI_SETTLE_MS,
setCount: (n) => { console.log('[PG] multi setCount', n, 'type=', type); shownCount.value = n },
})
}
const unbindMultiObserver = () => {
multiObserver?.disconnect()
multiObserver = null
}
/** 按 page.type 调度对应的串行挂载函数iOS 上九宫格/一大五小也走此路径) */
const runSequentialByType = async () => {
if (multiStarted) {
console.log('[PG] runSequentialByType skip (multiStarted)')
return
}
const type = props.page?.type
console.log('[PG] runSequentialByType start', { type, nineGridAnimate: props.nineGridAnimate, imgCount: (props.page?.images || []).length })
if (type === 'nine-grid') {
if (props.nineGridAnimate) await runNineSequential()
else await showNineInstant()
return
}
if (type === 'one-large-five-small') {
await runOlfsSequential()
return
}
// masonry / polaroid
await runMultiSequential()
}
const bindMultiObserver = async () => {
unbindMultiObserver()
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) {
console.warn('[PG] bindMultiObserver skip, type not in set:', props.page?.type, 'isIOS:', isIOSClient)
return
}
await nextTick()
const el = multiRootRef.value
console.log('[PG] bindMultiObserver', { type: props.page?.type, hasEl: !!el, imgCount: (props.page?.images || []).length, isIOS: isIOSClient })
if (!el || typeof IntersectionObserver === 'undefined') {
if (!multiStarted) {
multiStarted = true
console.log('[PG] no el/IO → runSequentialByType')
runSequentialByType()
}
return
}
// iOS 上九宫格/一大五小:跳过观察器直接串行挂载(反正不停滚,等到进视口才挂反而突兀)
if (isIOSClient && (props.page?.type === 'nine-grid' || props.page?.type === 'one-large-five-small')) {
if (!multiStarted) {
multiStarted = true
console.log('[PG] iOS direct → runSequentialByType')
runSequentialByType()
}
return
}
// masonry/polaroid 仍用观察器:露出 30% 再串行,避免进屏前播完
multiObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
console.log('[PG] IO entry', { isIntersecting: entry.isIntersecting, ratio: entry.intersectionRatio, type: props.page?.type })
if (!entry.isIntersecting) continue
multiObserver?.disconnect()
multiObserver = null
multiStarted = true
console.log('[PG] IO triggered → runSequentialByType')
runSequentialByType()
break
}
},
{ threshold: [0, 0.1, 0.2, 0.3, 0.5], rootMargin: '0px 0px -10% 0px' }
)
multiObserver.observe(el)
console.log('[PG] observer bound')
}
watch(
() => props.activate && HOLD_ACTIVATE_TYPES.has(props.page?.type),
async (on) => {
if (!on || readyEmitted) return
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()
}
},
{ flush: 'post' }
)
watch(
() => [props.page?.type, props.page?.images],
async () => {
seq.cancel()
olfsToken += 1
unbindMultiObserver()
multiStarted = false
shownCount.value = 0
readyEmitted = false
if (props.page?.type === 'nine-grid') {
if (!props.nineGridAnimate) preloadNineGridImages()
// iOS 自观测路径:仍需绑定观察器
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) await bindMultiObserver()
return
}
if (props.page?.type === 'one-large-five-small') {
// iOS 自观测路径:仍需绑定观察器
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) await bindMultiObserver()
return
}
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) {
await bindMultiObserver()
}
},
{ deep: true, flush: 'post' }
)
watch(
() => props.nineGridAnimate,
() => {
if (!props.nineGridAnimate) preloadNineGridImages()
}
)
onMounted(() => {
console.log('[PG] onMounted', { type: props.page?.type, imgCount: (props.page?.images || []).length, isIOS: isIOSClient, inSelfSet: SEQUENTIAL_SELF_TYPES.has(props.page?.type) })
if (!props.nineGridAnimate) preloadNineGridImages()
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) bindMultiObserver()
})
onUnmounted(() => {
seq.cancel()
olfsToken += 1
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) {
if (src) emit('preview-image', src)
}
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;
}
.g-masonry { column-count: 2; column-gap: 10px; }
.g-masonry-item { break-inside: avoid; margin-bottom: 10px; 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-item {
width: 42%; 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>