Files
hunli/vite-tailwindcss/src/components/PhotoGallery.vue

724 lines
21 KiB
Vue
Raw Normal View History

2026-07-31 09:05:15 +08:00
<template>
2026-08-01 14:55:21 +08:00
<div class="gallery">
2026-07-31 09:05:15 +08:00
<!-- 标题区 -->
<div v-reveal="'up'" class="text-center mb-5 px-4">
2026-08-01 09:15:40 +08:00
<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>
2026-07-31 14:43:54 +08:00
<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>
2026-07-31 09:05:15 +08:00
</div>
2026-07-31 11:36:19 +08:00
<!-- 单图默认居中卡幅 / 可配置宽度铺满 -->
2026-07-31 09:05:15 +08:00
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
2026-07-31 11:36:19 +08:00
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
2026-08-01 14:55:21 +08:00
<div :class="singleFrameClass">
<img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" />
</div>
2026-07-31 09:05:15 +08:00
</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="page.img1"
class="preview-img overlap-img"
decoding="async"
@click.stop="previewImage(page.img1)"
/>
</div>
2026-07-31 09:05:15 +08:00
</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="page.img2"
class="preview-img overlap-img"
decoding="async"
@click.stop="previewImage(page.img2)"
/>
</div>
2026-07-31 09:05:15 +08:00
</div>
</div>
<!-- 一大五小固定 6 槽占位 + 串行填内容大图就绪后再挂小图 -->
2026-08-02 18:19:12 +08:00
<div
v-else-if="page.type === 'one-large-five-small'"
2026-08-02 21:41:43 +08:00
ref="multiRootRef"
2026-08-02 18:19:12 +08:00
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>
2026-07-31 09:05:15 +08:00
</div>
2026-08-02 18:19:12 +08:00
<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>
2026-07-31 09:05:15 +08:00
</div>
</div>
<!-- 九宫格串行挂载 + immediate安卓 activateiOS 近视口 IO -->
2026-08-02 21:41:43 +08:00
<div v-else-if="page.type === 'nine-grid'" ref="multiRootRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
2026-08-01 15:16:13 +08:00
<div
v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx"
2026-08-01 16:01:05 +08:00
class="aspect-square relative nine-grid-slot"
2026-08-02 18:19:12 +08:00
:class="{ 'nine-grid-placeholder': imgIdx >= shownCount }"
2026-08-01 15:16:13 +08:00
>
<div
2026-08-02 18:19:12 +08:00
v-if="imgIdx < shownCount && nineGridAnimate"
2026-08-02 17:39:19 +08:00
v-reveal="getRevealConfig(imgIdx)"
class="absolute inset-0"
:class="frameClass"
2026-08-01 15:16:13 +08:00
>
<img
:src="img"
class="nine-grid-img preview-img"
2026-08-02 17:39:19 +08:00
decoding="async"
@click.stop="previewImage(img)"
/>
</div>
<div
2026-08-02 18:19:12 +08:00
v-else-if="imgIdx < shownCount"
2026-08-02 17:39:19 +08:00
class="absolute inset-0"
:class="frameClass"
>
<img
:src="img"
class="nine-grid-img preview-img"
loading="eager"
2026-08-02 16:11:35 +08:00
decoding="async"
2026-08-01 15:16:13 +08:00
@click.stop="previewImage(img)"
/>
2026-08-01 15:00:15 +08:00
</div>
2026-08-01 15:16:13 +08:00
</div>
2026-07-31 09:05:15 +08:00
</div>
<!-- 瀑布流固定槽位 + 串行挂载 -->
2026-08-02 18:19:12 +08:00
<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"
class="g-masonry-slot"
:class="{ 'g-masonry-placeholder': imgIdx >= shownCount }"
2026-08-02 18:19:12 +08:00
>
<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>
2026-07-31 09:05:15 +08:00
</div>
</div>
<!-- 轮播滑动 -->
<div v-else-if="page.type === 'carousel'" class="relative mt-6">
<div ref="trackRef" class="g-carousel-track no-scrollbar" @scroll="onScroll">
2026-07-31 10:29:14 +08:00
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
2026-08-01 14:55:21 +08:00
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
2026-07-31 09:05:15 +08:00
</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>
<!-- 拍立得固定槽位 + 串行挂载 -->
2026-08-02 18:19:12 +08:00
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
<div
v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx"
class="g-polaroid-slot"
:class="{ 'g-polaroid-placeholder': imgIdx >= shownCount }"
:style="imgIdx >= shownCount ? { transform: polaroidTransform(imgIdx) } : undefined"
2026-08-02 18:19:12 +08:00
>
<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>
2026-07-31 09:05:15 +08:00
</div>
</div>
<div v-else class="text-center text-[#8A8680] text-[12px] mt-6">未识别的版式{{ page.type }}</div>
</div>
</template>
<script setup>
2026-08-02 16:11:35 +08:00
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
2026-07-31 10:29:14 +08:00
import { borderClass } from '@/composables/borderStyles'
2026-08-02 18:19:12 +08:00
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
2026-08-02 18:19:12 +08:00
const OLFS_STEP_MS = 300
const OLFS_SETTLE_MS = 320
const OLFS_DECODE_TIMEOUT_ANDROID_MS = 1200
const OLFS_DECODE_TIMEOUT_IOS_MS = 400
2026-07-31 09:05:15 +08:00
/** 安卓九宫格/一大五小:父级 activate+停滚;其余自观测不停滚 */
2026-08-02 21:41:43 +08:00
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 HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small'])
/** 自观测串行:瀑布/拍立得双端;九宫格/一大五小仅 iOS安卓走 activate */
const SEQUENTIAL_SELF_TYPES = new Set(
isIOSClient
? ['masonry', 'polaroid', 'nine-grid', 'one-large-five-small']
: ['masonry', 'polaroid']
)
2026-08-01 15:00:15 +08:00
2026-07-31 09:05:15 +08:00
const props = defineProps({
page: { type: Object, required: true },
/** 重型版式:父级停滚后设为 true 才串行挂载(安卓) */
2026-08-01 15:00:15 +08:00
activate: { type: Boolean, default: false },
2026-08-02 18:19:12 +08:00
/** 九宫格:开启串行 v-reveal关闭则一次展示 */
2026-08-02 16:11:35 +08:00
nineGridAnimate: { type: Boolean, default: false },
2026-07-31 09:05:15 +08:00
})
2026-08-01 15:00:15 +08:00
const emit = defineEmits(['preview-image', 'ready'])
2026-08-02 18:19:12 +08:00
const shownCount = ref(0)
const multiRootRef = ref(null)
const olfsLargeImgRef = ref(null)
2026-08-01 15:00:15 +08:00
let readyEmitted = false
2026-08-02 16:11:35 +08:00
let preloadStarted = false
2026-08-02 18:19:12 +08:00
let multiObserver = null
let multiStarted = false
let olfsToken = 0
let overlapToken = 0
let overlapFirstAt = 0
let overlapSecondTimer = null
2026-07-31 09:05:15 +08:00
2026-08-02 18:19:12 +08:00
const seq = createSequentialMount()
2026-07-31 10:29:14 +08:00
const frameClass = computed(() => borderClass(props.page.borderStyle))
2026-08-02 17:39:19 +08:00
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
2026-08-01 15:16:13 +08:00
const clearOverlapSecondTimer = () => {
clearTimeout(overlapSecondTimer)
overlapSecondTimer = null
}
/** iOS 跳过 img.decode手动滚动时强制 decode 易触发内存压力);仅等 load/complete */
2026-08-02 18:19:12 +08:00
const waitImgReady = (img, timeoutMs) => {
if (!img) return Promise.resolve()
const waitLoad = () => {
2026-08-02 18:19:12 +08:00
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())
2026-08-02 18:19:12 +08:00
return Promise.race([
ready,
2026-08-02 18:19:12 +08:00
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
])
}
const emitReady = () => {
if (readyEmitted) return
readyEmitted = true
emit('ready')
}
/** 关闭九宫格动画时提前暖缓存 */
2026-08-02 16:11:35 +08:00
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
2026-08-01 15:16:13 +08:00
}
2026-08-02 16:11:35 +08:00
}
2026-08-01 15:16:13 +08:00
2026-08-02 21:41:43 +08:00
/** onMounted
* 实测反而更卡顿下载集中在画廊接近视口时已移除
* 若需预热应只针对单张且错开更长时间间隔且仅在用户即将到达时触发 */
2026-08-02 21:18:51 +08:00
2026-08-02 18:19:12 +08:00
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 },
2026-08-02 18:19:12 +08:00
})
if (!cancelled) emitReady()
2026-08-02 17:39:19 +08:00
}
2026-08-02 18:19:12 +08:00
const showNineInstant = async () => {
shownCount.value = (props.page.images || []).length
await nextTick()
emitReady()
}
/** 一大五小:先大图并等就绪,再慢挂小图 */
2026-08-02 18:19:12 +08:00
const runOlfsSequential = async () => {
const token = ++olfsToken
const total = Math.min(6, (props.page.images || []).length)
2026-08-02 16:11:35 +08:00
if (!total) {
2026-08-02 17:39:19 +08:00
emitReady()
2026-08-02 16:11:35 +08:00
return
}
2026-08-01 15:16:13 +08:00
2026-08-02 18:19:12 +08:00
shownCount.value = 0
await nextTick()
if (token !== olfsToken) return
2026-08-02 18:19:12 +08:00
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return
2026-08-02 18:19:12 +08:00
shownCount.value = 1
2026-08-02 17:39:19 +08:00
await nextTick()
const timeout = isIOSClient ? OLFS_DECODE_TIMEOUT_IOS_MS : OLFS_DECODE_TIMEOUT_ANDROID_MS
await waitImgReady(olfsLargeImgRef.value, timeout)
if (token !== olfsToken) return
2026-08-02 17:39:19 +08:00
2026-08-02 18:19:12 +08:00
for (let n = 2; n <= total; n += 1) {
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return
2026-08-02 18:19:12 +08:00
shownCount.value = n
2026-08-02 16:11:35 +08:00
await nextTick()
2026-08-01 15:16:13 +08:00
}
2026-08-02 16:11:35 +08:00
2026-08-02 18:19:12 +08:00
if (token !== olfsToken) return
await sleep(OLFS_SETTLE_MS)
2026-08-02 17:39:19 +08:00
emitReady()
}
2026-08-02 18:19:12 +08:00
const runMultiSequential = async () => {
2026-08-02 17:39:19 +08:00
const total = (props.page.images || []).length
const { cancelled } = await seq.run({
2026-08-02 18:19:12 +08:00
total,
stepMs: MULTI_STEP_MS,
settleMs: MULTI_SETTLE_MS,
setCount: (n) => { shownCount.value = n },
2026-08-02 18:19:12 +08:00
})
if (!cancelled) emitReady()
2026-08-02 18:19:12 +08:00
}
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 调度对应的串行挂载函数 */
2026-08-02 21:41:43 +08:00
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()
2026-08-02 21:41:43 +08:00
}
2026-08-02 18:19:12 +08:00
const bindMultiObserver = async () => {
unbindMultiObserver()
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) return
2026-08-02 16:11:35 +08:00
await nextTick()
2026-08-02 18:19:12 +08:00
const el = multiRootRef.value
if (!el || typeof IntersectionObserver === 'undefined') {
await startSequentialMount()
2026-08-02 18:19:12 +08:00
return
}
// 近视口再串行,避免页面加载即全量 decode不停滚
2026-08-02 18:19:12 +08:00
multiObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
2026-08-02 21:41:43 +08:00
if (!entry.isIntersecting) continue
2026-08-02 18:19:12 +08:00
multiObserver?.disconnect()
multiObserver = null
startSequentialMount()
2026-08-02 18:19:12 +08:00
break
}
},
2026-08-02 21:41:43 +08:00
{ threshold: [0, 0.1, 0.2, 0.3, 0.5], rootMargin: '0px 0px -10% 0px' }
2026-08-02 18:19:12 +08:00
)
multiObserver.observe(el)
2026-08-01 15:00:15 +08:00
}
const bindByType = async () => {
const type = props.page?.type
if (type === 'overlap') {
await bindOverlapObserver()
return
}
if (SEQUENTIAL_SELF_TYPES.has(type)) {
await bindMultiObserver()
} else if (props.activate && HOLD_ACTIVATE_TYPES.has(type)) {
await startSequentialMount()
}
}
2026-08-01 15:00:15 +08:00
watch(
2026-08-02 18:19:12 +08:00
() => props.activate && HOLD_ACTIVATE_TYPES.has(props.page?.type),
2026-08-01 15:00:15 +08:00
async (on) => {
if (!on) return
await startSequentialMount()
2026-08-01 15:00:15 +08:00
},
{ flush: 'post' }
)
2026-08-02 16:11:35 +08:00
watch(
() => [props.page?.type, props.page?.images, props.page?.img1, props.page?.img2],
2026-08-02 18:19:12 +08:00
async () => {
seq.cancel()
olfsToken += 1
overlapToken += 1
clearOverlapSecondTimer()
2026-08-02 18:19:12 +08:00
unbindMultiObserver()
multiStarted = false
shownCount.value = 0
readyEmitted = false
preloadStarted = false
overlapFirstAt = 0
if (props.page?.type === 'nine-grid' && !props.nineGridAnimate) {
preloadNineGridImages()
2026-08-02 18:19:12 +08:00
}
await bindByType()
2026-08-02 18:19:12 +08:00
},
{ deep: true, flush: 'post' }
)
watch(
() => props.nineGridAnimate,
2026-08-02 16:11:35 +08:00
() => {
if (!props.nineGridAnimate) preloadNineGridImages()
2026-08-02 18:19:12 +08:00
}
2026-08-02 16:11:35 +08:00
)
onMounted(() => {
if (!props.nineGridAnimate) preloadNineGridImages()
bindByType()
2026-08-02 16:11:35 +08:00
})
2026-08-02 17:39:19 +08:00
onUnmounted(() => {
2026-08-02 18:19:12 +08:00
seq.cancel()
olfsToken += 1
overlapToken += 1
clearOverlapSecondTimer()
2026-08-02 18:19:12 +08:00
unbindMultiObserver()
2026-08-02 17:39:19 +08:00
})
function getRevealConfig(imgIdx) {
const row = Math.floor(imgIdx / 3)
const col = imgIdx % 3
if (imgIdx === 4) {
2026-08-02 18:19:12 +08:00
return { variant: 'scale', delay: 0, immediate: true }
2026-08-02 17:39:19 +08:00
}
if (col === 1) {
return {
variant: row === 0 ? 'down' : 'up',
delay: 0,
2026-08-02 18:19:12 +08:00
immediate: true,
2026-08-02 17:39:19 +08:00
}
}
return {
variant: col === 0 ? 'left' : 'right',
delay: 0,
2026-08-02 18:19:12 +08:00
immediate: true,
2026-08-02 17:39:19 +08:00
}
}
2026-08-02 16:11:35 +08:00
2026-07-31 11:36:19 +08:00
const singleWrapClass = computed(() =>
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
)
const singleWrapStyle = computed(() => {
if (props.page.singleWidth === 'full') {
2026-07-31 12:13:41 +08:00
const bleed = { width: 'calc(100% + 48px)', marginLeft: '-24px', marginRight: '-24px' }
return props.page.singleKeepRatio ? bleed : { ...bleed, aspectRatio: '4 / 5' }
2026-07-31 11:36:19 +08:00
}
return { aspectRatio: '4 / 5' }
})
const singleFrameClass = computed(() =>
frameClass.value + (props.page.singleWidth === 'full' && props.page.singleKeepRatio ? ' gf-natural' : '')
)
2026-07-31 09:05:15 +08:00
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
}
2026-08-01 12:36:58 +08:00
function previewImage(src) {
if (src) emit('preview-image', src)
}
2026-07-31 09:05:15 +08:00
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)`
}
2026-08-01 10:34:21 +08:00
2026-07-31 09:05:15 +08:00
</script>
<style scoped>
2026-07-31 14:43:54 +08:00
.gallery { width: 100%; font-family: var(--font-sans); }
2026-08-01 12:36:58 +08:00
.preview-img { cursor: zoom-in; }
2026-08-01 09:15:40 +08:00
.gallery-title {
font-family: var(--font-calligraphy);
font-weight: 700;
}
2026-07-31 14:43:54 +08:00
.gallery-desc { font-family: var(--font-kai); }
2026-07-31 09:05:15 +08:00
2026-07-31 17:21:41 +08:00
.nine-grid-img {
width: 100%;
height: 100%;
object-fit: cover;
2026-08-02 16:11:35 +08:00
display: block;
2026-07-31 17:21:41 +08:00
}
2026-08-01 16:01:05 +08:00
.nine-grid-slot {
background: transparent;
2026-08-02 16:11:35 +08:00
contain: layout paint;
}
2026-08-02 17:39:19 +08:00
.nine-grid-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
2026-08-01 15:00:15 +08:00
}
2026-08-02 18:19:12 +08:00
.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;
}
2026-07-31 17:21:41 +08:00
.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;
}
2026-07-31 11:36:19 +08:00
2026-07-31 09:05:15 +08:00
.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; }
2026-07-31 09:05:15 +08:00
.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;
}
2026-07-31 09:05:15 +08:00
.g-polaroid-item {
width: 100%; background: #fff; padding: 8px 8px 24px; border-radius: 2px;
2026-07-31 09:05:15 +08:00
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>