2026-07-31 09:05:15 +08:00
|
|
|
|
<template>
|
2026-08-01 14:55:21 +08:00
|
|
|
|
<div class="gallery">
|
2026-08-12 13:52:54 +08:00
|
|
|
|
<!-- 标题区:完整模板主题用统一章节头,风格包保留经典排版 -->
|
|
|
|
|
|
<SectionHeading
|
|
|
|
|
|
v-if="layoutTheme"
|
|
|
|
|
|
v-reveal="'up'"
|
|
|
|
|
|
:index="pageIndex"
|
|
|
|
|
|
:kicker="page.subtitle"
|
|
|
|
|
|
:title="page.title"
|
|
|
|
|
|
:note="page.desc"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div v-else v-reveal="'up'" class="text-center mb-5 px-4">
|
|
|
|
|
|
<span class="gallery-title calligraphy text-xl text-[var(--c-primary)] tracking-[0.3em] font-bold block mb-2">{{ page.title }}</span>
|
|
|
|
|
|
<span class="gallery-subtitle text-[9px] text-[var(--c-muted)] first-letter:uppercase lowercase tracking-[0.4em] block mb-4">{{ page.subtitle }}</span>
|
|
|
|
|
|
<span class="gallery-desc text-[12px] text-[var(--c-muted)] 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-08-12 13:52:54 +08:00
|
|
|
|
<div v-if="page.type === 'single'" v-reveal="{ variant: singleRevealVariant, 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">
|
2026-08-03 08:57:18 +08:00
|
|
|
|
<img :src="displayImg1" class="preview-img" @click.stop="previewImage(originalImg1)" />
|
2026-08-12 13:52:54 +08:00
|
|
|
|
<span v-if="showPostmark" class="gf-postmark" aria-hidden="true"></span>
|
2026-08-01 14:55:21 +08:00
|
|
|
|
</div>
|
2026-07-31 09:05:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-03 08:31:29 +08:00
|
|
|
|
<!-- 错落双图:固定 2 槽;视口 30% / 50% 分步挂载,不停滚 -->
|
2026-08-03 08:01:15 +08:00
|
|
|
|
<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
|
2026-08-03 08:57:18 +08:00
|
|
|
|
:src="displayImg1"
|
2026-08-03 08:01:15 +08:00
|
|
|
|
class="preview-img overlap-img"
|
|
|
|
|
|
decoding="async"
|
2026-08-03 08:57:18 +08:00
|
|
|
|
@click.stop="previewImage(originalImg1)"
|
2026-08-03 08:01:15 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-31 09:05:15 +08:00
|
|
|
|
</div>
|
2026-08-03 08:01:15 +08:00
|
|
|
|
<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
|
2026-08-03 08:57:18 +08:00
|
|
|
|
:src="displayImg2"
|
2026-08-03 08:01:15 +08:00
|
|
|
|
class="preview-img overlap-img"
|
|
|
|
|
|
decoding="async"
|
2026-08-03 08:57:18 +08:00
|
|
|
|
@click.stop="previewImage(originalImg2)"
|
2026-08-03 08:01:15 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-31 09:05:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-03 08:01:15 +08:00
|
|
|
|
<!-- 一大五小:固定 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"
|
2026-08-03 08:57:18 +08:00
|
|
|
|
:src="displayAt(0)"
|
2026-08-02 18:19:12 +08:00
|
|
|
|
class="preview-img olfs-img"
|
|
|
|
|
|
decoding="async"
|
2026-08-03 08:57:18 +08:00
|
|
|
|
@click.stop="previewImage(originalAt(0))"
|
2026-08-02 18:19:12 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</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
|
2026-08-03 08:57:18 +08:00
|
|
|
|
:src="displayAt(i)"
|
2026-08-02 18:19:12 +08:00
|
|
|
|
class="preview-img olfs-img"
|
|
|
|
|
|
decoding="async"
|
2026-08-03 08:57:18 +08:00
|
|
|
|
@click.stop="previewImage(originalAt(i))"
|
2026-08-02 18:19:12 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-31 09:05:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-03 08:57:18 +08:00
|
|
|
|
<!-- 九宫格:近视口 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
|
2026-08-03 08:57:18 +08:00
|
|
|
|
v-for="(_, imgIdx) in (page.images || [])"
|
2026-08-01 15:16:13 +08:00
|
|
|
|
: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
|
2026-08-03 08:57:18 +08:00
|
|
|
|
:src="displayAt(imgIdx)"
|
2026-08-01 15:16:13 +08:00
|
|
|
|
class="nine-grid-img preview-img"
|
2026-08-02 17:39:19 +08:00
|
|
|
|
decoding="async"
|
2026-08-03 08:57:18 +08:00
|
|
|
|
@click.stop="previewImage(originalAt(imgIdx))"
|
2026-08-02 17:39:19 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</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
|
2026-08-03 08:57:18 +08:00
|
|
|
|
:src="displayAt(imgIdx)"
|
2026-08-02 17:39:19 +08:00
|
|
|
|
class="nine-grid-img preview-img"
|
|
|
|
|
|
loading="eager"
|
2026-08-02 16:11:35 +08:00
|
|
|
|
decoding="async"
|
2026-08-03 08:57:18 +08:00
|
|
|
|
@click.stop="previewImage(originalAt(imgIdx))"
|
2026-08-01 15:16:13 +08:00
|
|
|
|
/>
|
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-03 08:01:15 +08:00
|
|
|
|
<!-- 瀑布流:固定槽位 + 串行挂载 -->
|
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
|
2026-08-03 08:57:18 +08:00
|
|
|
|
v-for="(_, imgIdx) in (page.images || [])"
|
2026-08-02 18:19:12 +08:00
|
|
|
|
:key="imgIdx"
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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"
|
|
|
|
|
|
>
|
2026-08-03 08:57:18 +08:00
|
|
|
|
<img :src="displayAt(imgIdx)" class="preview-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
|
2026-08-02 18:19:12 +08:00
|
|
|
|
</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-08-03 08:57:18 +08:00
|
|
|
|
<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))" />
|
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">
|
2026-08-03 08:57:18 +08:00
|
|
|
|
<button v-for="(_, imgIdx) in page.images" :key="imgIdx"
|
2026-07-31 09:05:15 +08:00
|
|
|
|
class="g-dot" :class="{ active: imgIdx === index }" @click="goTo(imgIdx)"></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-03 08:01:15 +08:00
|
|
|
|
<!-- 拍立得:固定槽位 + 串行挂载 -->
|
2026-08-02 18:19:12 +08:00
|
|
|
|
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
|
|
|
|
|
|
<div
|
2026-08-03 08:57:18 +08:00
|
|
|
|
v-for="(_, imgIdx) in (page.images || [])"
|
2026-08-02 18:19:12 +08:00
|
|
|
|
:key="imgIdx"
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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) }"
|
|
|
|
|
|
>
|
2026-08-03 08:57:18 +08:00
|
|
|
|
<img :src="displayAt(imgIdx)" class="preview-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
|
2026-08-02 18:19:12 +08:00
|
|
|
|
</div>
|
2026-07-31 09:05:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-12 13:52:54 +08:00
|
|
|
|
<!-- 爱心拼接:绝对定位心形轮廓 + 串行挂载;拼合完成后心跳一次并飘散小心心 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="page.type === 'heart-collage'"
|
|
|
|
|
|
ref="multiRootRef"
|
|
|
|
|
|
class="g-heart mt-6"
|
|
|
|
|
|
:class="{ 'g-heart-done': heartDone }"
|
|
|
|
|
|
>
|
2026-08-04 10:26:00 +08:00
|
|
|
|
<div
|
2026-08-14 21:02:24 +08:00
|
|
|
|
v-for="(slot, imgIdx) in heartSlots"
|
|
|
|
|
|
:key="`${page.heartLayout || 'classic'}-${imgIdx}`"
|
2026-08-04 10:26:00 +08:00
|
|
|
|
class="g-heart-slot"
|
|
|
|
|
|
:class="{ 'g-heart-placeholder': imgIdx >= shownCount }"
|
|
|
|
|
|
:style="heartSlotStyle(slot)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="imgIdx < shownCount"
|
|
|
|
|
|
v-reveal="{ variant: heartReveal(imgIdx), immediate: true }"
|
|
|
|
|
|
class="absolute inset-0 g-heart-cell"
|
|
|
|
|
|
:class="frameClass"
|
2026-08-14 21:02:24 +08:00
|
|
|
|
:style="heartCellShape(slot)"
|
2026-08-04 10:26:00 +08:00
|
|
|
|
>
|
|
|
|
|
|
<img
|
|
|
|
|
|
:src="displayAt(imgIdx)"
|
|
|
|
|
|
class="preview-img g-heart-img"
|
|
|
|
|
|
decoding="async"
|
2026-08-04 12:24:51 +08:00
|
|
|
|
:style="{ objectPosition: heartFocusCssAt(page, imgIdx) }"
|
2026-08-04 10:26:00 +08:00
|
|
|
|
@click.stop="previewImage(originalAt(imgIdx))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-12 13:52:54 +08:00
|
|
|
|
<template v-if="heartDone">
|
|
|
|
|
|
<span class="g-heart-spark g-heart-spark-1" aria-hidden="true">♥</span>
|
|
|
|
|
|
<span class="g-heart-spark g-heart-spark-2" aria-hidden="true">♥</span>
|
|
|
|
|
|
<span class="g-heart-spark g-heart-spark-3" aria-hidden="true">♥</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 三联屏:三竖条并排,中列上浮错落 -->
|
|
|
|
|
|
<div v-else-if="page.type === 'triptych'" class="g-triptych mt-6">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(_, imgIdx) in (page.images || []).slice(0, 3)"
|
|
|
|
|
|
:key="imgIdx"
|
|
|
|
|
|
v-reveal="{ variant: imgIdx === 0 ? 'left' : imgIdx === 2 ? 'right' : 'up', delay: imgIdx * 130 }"
|
|
|
|
|
|
class="g-triptych-item"
|
|
|
|
|
|
:class="[frameClass, { 'g-triptych-mid': imgIdx === 1 }]"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img :src="displayAt(imgIdx)" class="preview-img g-cover-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 胶片连拍:深色胶片条 + 横滑帧 -->
|
|
|
|
|
|
<div v-else-if="page.type === 'filmstrip'" v-reveal="{ variant: 'up', delay: 100 }" class="g-filmstrip mt-6">
|
|
|
|
|
|
<div class="g-filmstrip-track no-scrollbar">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(_, imgIdx) in (page.images || [])"
|
|
|
|
|
|
:key="imgIdx"
|
|
|
|
|
|
class="g-filmstrip-frame"
|
|
|
|
|
|
:class="frameClass"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img :src="displayAt(imgIdx)" class="preview-img g-cover-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 扇形叠影:五张卡入场后从底部枢轴展开成扇 -->
|
|
|
|
|
|
<div v-else-if="page.type === 'fan'" v-reveal="{ variant: 'scale', delay: 120 }" class="g-fan mt-4">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(_, imgIdx) in (page.images || []).slice(0, 5)"
|
|
|
|
|
|
:key="imgIdx"
|
|
|
|
|
|
class="g-fan-item"
|
|
|
|
|
|
:class="frameClass"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img :src="displayAt(imgIdx)" class="preview-img g-cover-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 杂志拼贴:横竖交错三行 grid + 串行挂载 -->
|
|
|
|
|
|
<div v-else-if="page.type === 'mosaic'" ref="multiRootRef" class="g-mosaic mt-6">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(_, imgIdx) in (page.images || []).slice(0, 6)"
|
|
|
|
|
|
:key="imgIdx"
|
|
|
|
|
|
class="g-mosaic-slot"
|
|
|
|
|
|
:class="[`g-mosaic-area-${imgIdx + 1}`, { 'g-mosaic-placeholder': imgIdx >= shownCount }]"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="imgIdx < shownCount"
|
|
|
|
|
|
v-reveal="{ variant: imgIdx % 2 === 0 ? 'left' : 'right', immediate: true }"
|
|
|
|
|
|
class="absolute inset-0"
|
|
|
|
|
|
:class="frameClass"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img :src="displayAt(imgIdx)" class="preview-img g-cover-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-04 10:26:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-12 13:52:54 +08:00
|
|
|
|
<div v-else class="text-center text-[var(--c-muted)] text-[12px] mt-6">未识别的版式:{{ page.type }}</div>
|
2026-07-31 09:05:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2026-08-02 16:11:35 +08:00
|
|
|
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
2026-08-12 13:52:54 +08:00
|
|
|
|
import { borderClass, borderStyleMeta } from '@/composables/borderStyles'
|
2026-08-02 18:19:12 +08:00
|
|
|
|
import { createSequentialMount } from '@/composables/useSequentialMount'
|
2026-08-14 21:02:24 +08:00
|
|
|
|
import { heartSlotsFor, heartSlotStyle, heartFocusCssAt, heartCellShape } from '@/composables/heartCollage'
|
2026-08-12 13:52:54 +08:00
|
|
|
|
import SectionHeading from '@/components/themes/SectionHeading.vue'
|
2026-08-02 18:19:12 +08:00
|
|
|
|
|
|
|
|
|
|
const NINE_STEP_MS = 300
|
|
|
|
|
|
const NINE_SETTLE_MS = 400
|
2026-08-03 08:31:29 +08:00
|
|
|
|
/** 瀑布/拍立得不停滚,步进略拉长减轻同时 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
|
2026-08-03 08:01:15 +08:00
|
|
|
|
const OLFS_DECODE_TIMEOUT_ANDROID_MS = 1200
|
|
|
|
|
|
const OLFS_DECODE_TIMEOUT_IOS_MS = 400
|
2026-07-31 09:05:15 +08:00
|
|
|
|
|
2026-08-03 08:57:18 +08:00
|
|
|
|
/** 仅用于 OLFS 是否跳过强制 decode */
|
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
|
|
|
|
|
|
})()
|
2026-08-03 08:01:15 +08:00
|
|
|
|
|
2026-08-03 08:57:18 +08:00
|
|
|
|
/** 双端均不停滚:近视口 IO 串行挂载 */
|
|
|
|
|
|
const SEQUENTIAL_SELF_TYPES = new Set([
|
|
|
|
|
|
'masonry',
|
|
|
|
|
|
'polaroid',
|
|
|
|
|
|
'nine-grid',
|
|
|
|
|
|
'one-large-five-small',
|
2026-08-04 10:26:00 +08:00
|
|
|
|
'heart-collage',
|
2026-08-12 13:52:54 +08:00
|
|
|
|
'mosaic',
|
2026-08-03 08:57:18 +08:00
|
|
|
|
])
|
2026-08-01 15:00:15 +08:00
|
|
|
|
|
2026-07-31 09:05:15 +08:00
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
page: { type: Object, required: true },
|
2026-08-02 18:19:12 +08:00
|
|
|
|
/** 九宫格:开启串行 v-reveal;关闭则一次展示 */
|
2026-08-02 16:11:35 +08:00
|
|
|
|
nineGridAnimate: { type: Boolean, default: false },
|
2026-08-12 13:52:54 +08:00
|
|
|
|
/** 完整模板主题(magazine/newspaper/pixel/zen):标题区换统一章节头 */
|
|
|
|
|
|
layoutTheme: { type: Boolean, default: false },
|
|
|
|
|
|
/** 章节序号(完整模板的编号章节头用) */
|
|
|
|
|
|
pageIndex: { type: Number, default: 0 },
|
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
|
2026-08-03 08:31:29 +08:00
|
|
|
|
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-03 08:57:18 +08:00
|
|
|
|
/** 展示:缩放图优先;预览:原图优先 */
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
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
|
|
|
|
|
2026-08-03 08:31:29 +08:00
|
|
|
|
const clearOverlapSecondTimer = () => {
|
|
|
|
|
|
clearTimeout(overlapSecondTimer)
|
|
|
|
|
|
overlapSecondTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 08:01:15 +08:00
|
|
|
|
/** iOS 跳过 img.decode(手动滚动时强制 decode 易触发内存压力);仅等 load/complete */
|
2026-08-02 18:19:12 +08:00
|
|
|
|
const waitImgReady = (img, timeoutMs) => {
|
|
|
|
|
|
if (!img) return Promise.resolve()
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-08-03 08:01:15 +08:00
|
|
|
|
const ready = isIOSClient
|
|
|
|
|
|
? waitLoad()
|
|
|
|
|
|
: (typeof img.decode === 'function' ? img.decode().catch(() => {}) : waitLoad())
|
2026-08-02 18:19:12 +08:00
|
|
|
|
return Promise.race([
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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-03 08:57:18 +08:00
|
|
|
|
/** 关闭九宫格动画时提前暖缓存(优先展示用缩放图) */
|
2026-08-02 16:11:35 +08:00
|
|
|
|
const preloadNineGridImages = () => {
|
|
|
|
|
|
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
|
2026-08-03 08:57:18 +08:00
|
|
|
|
const urls = (props.page.images || [])
|
|
|
|
|
|
.map((_, i) => displayAt(i))
|
|
|
|
|
|
.filter(Boolean)
|
2026-08-02 16:11:35 +08:00
|
|
|
|
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,
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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-03 08:01:15 +08:00
|
|
|
|
/** 一大五小:先大图并等就绪,再慢挂小图 */
|
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()
|
2026-08-03 08:01:15 +08:00
|
|
|
|
if (token !== olfsToken) return
|
2026-08-02 18:19:12 +08:00
|
|
|
|
|
|
|
|
|
|
await sleep(OLFS_STEP_MS)
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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()
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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)
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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
|
2026-08-03 08:01:15 +08:00
|
|
|
|
const { cancelled } = await seq.run({
|
2026-08-02 18:19:12 +08:00
|
|
|
|
total,
|
|
|
|
|
|
stepMs: MULTI_STEP_MS,
|
|
|
|
|
|
settleMs: MULTI_SETTLE_MS,
|
2026-08-03 08:01:15 +08:00
|
|
|
|
setCount: (n) => { shownCount.value = n },
|
2026-08-02 18:19:12 +08:00
|
|
|
|
})
|
2026-08-03 08:01:15 +08:00
|
|
|
|
if (!cancelled) emitReady()
|
2026-08-02 18:19:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const unbindMultiObserver = () => {
|
|
|
|
|
|
multiObserver?.disconnect()
|
|
|
|
|
|
multiObserver = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 08:31:29 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 08:01:15 +08:00
|
|
|
|
/** 按 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
|
|
|
|
|
|
}
|
2026-08-12 13:52:54 +08:00
|
|
|
|
if (type === 'masonry' || type === 'polaroid' || type === 'heart-collage' || type === 'mosaic') {
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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()
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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') {
|
2026-08-03 08:01:15 +08:00
|
|
|
|
await startSequentialMount()
|
2026-08-02 18:19:12 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-03 08:31:29 +08:00
|
|
|
|
// 近视口再串行,避免页面加载即全量 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
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 08:31:29 +08:00
|
|
|
|
const bindByType = async () => {
|
|
|
|
|
|
const type = props.page?.type
|
|
|
|
|
|
if (type === 'overlap') {
|
|
|
|
|
|
await bindOverlapObserver()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (SEQUENTIAL_SELF_TYPES.has(type)) {
|
|
|
|
|
|
await bindMultiObserver()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-01 15:00:15 +08:00
|
|
|
|
watch(
|
2026-08-03 08:57:18 +08:00
|
|
|
|
() => [
|
|
|
|
|
|
props.page?.type,
|
|
|
|
|
|
props.page?.images,
|
|
|
|
|
|
props.page?.imagesResized,
|
|
|
|
|
|
props.page?.img1,
|
|
|
|
|
|
props.page?.img2,
|
|
|
|
|
|
props.page?.img1Resized,
|
|
|
|
|
|
props.page?.img2Resized,
|
|
|
|
|
|
],
|
2026-08-02 18:19:12 +08:00
|
|
|
|
async () => {
|
|
|
|
|
|
seq.cancel()
|
|
|
|
|
|
olfsToken += 1
|
2026-08-03 08:31:29 +08:00
|
|
|
|
overlapToken += 1
|
|
|
|
|
|
clearOverlapSecondTimer()
|
2026-08-02 18:19:12 +08:00
|
|
|
|
unbindMultiObserver()
|
|
|
|
|
|
multiStarted = false
|
|
|
|
|
|
shownCount.value = 0
|
|
|
|
|
|
readyEmitted = false
|
2026-08-03 08:01:15 +08:00
|
|
|
|
preloadStarted = false
|
2026-08-03 08:31:29 +08:00
|
|
|
|
overlapFirstAt = 0
|
2026-08-03 08:01:15 +08:00
|
|
|
|
if (props.page?.type === 'nine-grid' && !props.nineGridAnimate) {
|
|
|
|
|
|
preloadNineGridImages()
|
2026-08-02 18:19:12 +08:00
|
|
|
|
}
|
2026-08-03 08:31:29 +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()
|
2026-08-03 08:31:29 +08:00
|
|
|
|
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
|
2026-08-03 08:31:29 +08:00
|
|
|
|
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-08-12 13:52:54 +08:00
|
|
|
|
/** 当前边框元数据(分组/是否异形裁切/推荐宽高比) */
|
|
|
|
|
|
const singleStyleMeta = computed(() => borderStyleMeta(props.page.borderStyle))
|
|
|
|
|
|
/** 明信片·异形/横幅风景分组用飞入入场,其余保持缩放入场 */
|
|
|
|
|
|
const singleRevealVariant = computed(() =>
|
|
|
|
|
|
['postcard', 'scenic'].includes(singleStyleMeta.value?.group) ? 'postcard' : 'scale'
|
2026-07-31 11:36:19 +08:00
|
|
|
|
)
|
2026-08-12 13:52:54 +08:00
|
|
|
|
/** 邮戳盖章:仅 明信片/邮票 两种边框叠加 */
|
|
|
|
|
|
const showPostmark = computed(() => ['postcard', 'stamp'].includes(props.page.borderStyle))
|
|
|
|
|
|
|
|
|
|
|
|
const singleWrapClass = computed(() => [
|
|
|
|
|
|
props.page.singleWidth === 'full'
|
|
|
|
|
|
? 'max-w-none'
|
|
|
|
|
|
: (singleStyleMeta.value?.group === 'scenic' ? 'max-w-[340px]' : 'max-w-[300px]'),
|
|
|
|
|
|
// mask 裁切类的投影由外层 drop-shadow 提供(box-shadow 会被 mask 裁掉)
|
|
|
|
|
|
singleStyleMeta.value?.shaped ? 'gf-shape-shadow' : '',
|
|
|
|
|
|
].filter(Boolean).join(' '))
|
2026-07-31 11:36:19 +08:00
|
|
|
|
const singleWrapStyle = computed(() => {
|
2026-08-12 13:52:54 +08:00
|
|
|
|
const ratio = singleStyleMeta.value?.ratio || '4 / 5'
|
2026-07-31 11:36:19 +08:00
|
|
|
|
if (props.page.singleWidth === 'full') {
|
2026-07-31 12:13:41 +08:00
|
|
|
|
const bleed = { width: 'calc(100% + 48px)', marginLeft: '-24px', marginRight: '-24px' }
|
2026-08-12 13:52:54 +08:00
|
|
|
|
return props.page.singleKeepRatio ? bleed : { ...bleed, aspectRatio: ratio }
|
2026-07-31 11:36:19 +08:00
|
|
|
|
}
|
2026-08-12 13:52:54 +08:00
|
|
|
|
return { aspectRatio: ratio }
|
2026-07-31 11:36:19 +08:00
|
|
|
|
})
|
|
|
|
|
|
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) {
|
2026-08-03 08:57:18 +08:00
|
|
|
|
const url = typeof src === 'string' ? src.trim() : String(src?.value || src || '').trim()
|
|
|
|
|
|
if (url && url !== '[object Object]') emit('preview-image', url)
|
2026-08-01 12:36:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
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-08-04 10:26:00 +08:00
|
|
|
|
/** 爱心:左右对称入场,中轴与心尖用缩放 */
|
2026-08-14 21:02:24 +08:00
|
|
|
|
/** 当前爱心拼接方案的槽位(page.heartLayout 可选,classic 默认) */
|
|
|
|
|
|
const heartSlots = computed(() => heartSlotsFor(props.page))
|
|
|
|
|
|
|
2026-08-04 10:26:00 +08:00
|
|
|
|
function heartReveal(i) {
|
2026-08-14 21:02:24 +08:00
|
|
|
|
// 按槽位横向位置决定入场方向:左半从左入、右半从右入、中轴缩放
|
|
|
|
|
|
const slot = heartSlots.value[i]
|
|
|
|
|
|
if (!slot) return 'up'
|
|
|
|
|
|
const cx = slot.l + slot.w / 2
|
|
|
|
|
|
if (cx > 42 && cx < 58) return 'scale'
|
|
|
|
|
|
return cx <= 42 ? 'left' : 'right'
|
2026-08-04 10:26:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 13:52:54 +08:00
|
|
|
|
/** 爱心拼合完成:触发一次心跳 + 小心心飘散 */
|
|
|
|
|
|
const heartDone = computed(() =>
|
2026-08-14 21:02:24 +08:00
|
|
|
|
props.page?.type === 'heart-collage' && shownCount.value >= heartSlots.value.length
|
2026-08-12 13:52:54 +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
|
|
|
|
|
2026-08-01 17:13:49 +08:00
|
|
|
|
.overlap-wrap {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 420px;
|
|
|
|
|
|
margin-inline: auto;
|
|
|
|
|
|
height: clamp(360px, 56vh, 520px);
|
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}
|
2026-08-03 08:01:15 +08:00
|
|
|
|
.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; }
|
2026-08-03 08:01:15 +08:00
|
|
|
|
.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; }
|
2026-08-03 12:56:40 +08:00
|
|
|
|
|
|
|
|
|
|
@media (min-width: 768px) {
|
|
|
|
|
|
.gallery {
|
|
|
|
|
|
max-width: 400px;
|
|
|
|
|
|
margin-inline: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
.overlap-wrap {
|
|
|
|
|
|
max-width: 400px;
|
|
|
|
|
|
height: clamp(340px, 48vh, 460px);
|
|
|
|
|
|
}
|
2026-08-04 10:26:00 +08:00
|
|
|
|
.g-heart {
|
|
|
|
|
|
max-width: 400px;
|
|
|
|
|
|
}
|
2026-08-03 12:56:40 +08:00
|
|
|
|
.g-carousel-slide {
|
|
|
|
|
|
height: min(52vh, 420px);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-31 09:05:15 +08:00
|
|
|
|
.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;
|
2026-08-12 13:52:54 +08:00
|
|
|
|
background: rgba(255,255,255,0.78); color: var(--c-primary); font-size: 20px; line-height: 1;
|
2026-07-31 09:05:15 +08:00
|
|
|
|
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; }
|
2026-08-12 13:52:54 +08:00
|
|
|
|
.g-dot { width: 7px; height: 7px; border-radius: 9999px; border: none; background: rgba(var(--c-primary-rgb),0.3); padding: 0; cursor: pointer; transition: all 0.3s; }
|
|
|
|
|
|
.g-dot.active { background: var(--c-primary); width: 18px; border-radius: 9999px; }
|
2026-07-31 09:05:15 +08:00
|
|
|
|
|
2026-08-04 10:26:00 +08:00
|
|
|
|
/* 爱心拼接:绝对定位块状心形 + 淡囍底纹 */
|
|
|
|
|
|
.g-heart {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 400px;
|
|
|
|
|
|
margin-inline: auto;
|
|
|
|
|
|
aspect-ratio: 0.8 / 1;
|
|
|
|
|
|
padding: 4px 2px 8px;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
isolation: isolate;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-heart::before,
|
|
|
|
|
|
.g-heart::after {
|
|
|
|
|
|
content: '囍';
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
z-index: 0;
|
|
|
|
|
|
font-family: var(--font-calligraphy), serif;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: #c97878;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-heart::before {
|
|
|
|
|
|
top: 22%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
font-size: clamp(64px, 24vw, 108px);
|
|
|
|
|
|
opacity: 0.07;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-heart::after {
|
|
|
|
|
|
bottom: 10%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
font-size: clamp(36px, 12vw, 56px);
|
|
|
|
|
|
opacity: 0.05;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-heart-slot {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
z-index: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
contain: layout paint;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-heart-placeholder {
|
|
|
|
|
|
visibility: hidden;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
background: transparent !important;
|
|
|
|
|
|
border: none !important;
|
|
|
|
|
|
box-shadow: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-heart-cell {
|
|
|
|
|
|
overflow: hidden;
|
2026-08-12 13:52:54 +08:00
|
|
|
|
border-radius: 5px;
|
2026-08-04 10:26:00 +08:00
|
|
|
|
transition: transform 0.35s ease, box-shadow 0.35s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (hover: hover) {
|
|
|
|
|
|
.g-heart-cell:hover {
|
|
|
|
|
|
transform: scale(1.03);
|
|
|
|
|
|
z-index: 3;
|
|
|
|
|
|
box-shadow: 0 6px 18px rgba(80, 50, 40, 0.16);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-heart-img {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 13:52:54 +08:00
|
|
|
|
/* 拼合完成:整颗心轻跳两下(transform 不影响 absolute 子块布局) */
|
|
|
|
|
|
.g-heart-done {
|
|
|
|
|
|
animation: gHeartBeat 1.15s ease-in-out 0.2s 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes gHeartBeat {
|
|
|
|
|
|
0%, 100% { transform: scale(1); }
|
|
|
|
|
|
18% { transform: scale(1.035); }
|
|
|
|
|
|
36% { transform: scale(1); }
|
|
|
|
|
|
54% { transform: scale(1.02); }
|
|
|
|
|
|
70% { transform: scale(1); }
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 完成后从心口飘散的小心心 */
|
|
|
|
|
|
.g-heart-spark {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
z-index: 4;
|
|
|
|
|
|
color: #d9868d;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
animation: gHeartSpark 1.6s ease-out forwards;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-heart-spark-1 { left: 20%; top: 24%; font-size: 15px; animation-delay: 0.35s; }
|
|
|
|
|
|
.g-heart-spark-2 { right: 17%; top: 20%; font-size: 11px; animation-delay: 0.55s; }
|
|
|
|
|
|
.g-heart-spark-3 { left: 47%; top: 7%; font-size: 13px; animation-delay: 0.75s; }
|
|
|
|
|
|
@keyframes gHeartSpark {
|
|
|
|
|
|
0% { opacity: 0; transform: translateY(8px) scale(0.5); }
|
|
|
|
|
|
25% { opacity: 0.9; }
|
|
|
|
|
|
100% { opacity: 0; transform: translateY(-36px) scale(1.2) rotate(14deg); }
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
|
.g-heart-done { animation: none; }
|
|
|
|
|
|
.g-heart-spark { display: none; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 09:05:15 +08:00
|
|
|
|
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
|
2026-08-03 08:01:15 +08:00
|
|
|
|
.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 {
|
2026-08-03 08:01:15 +08:00
|
|
|
|
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; }
|
|
|
|
|
|
|
2026-08-12 13:52:54 +08:00
|
|
|
|
/* 通用:新版式内图片铺满裁切 */
|
|
|
|
|
|
.g-cover-img {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 三联屏:三竖条,中列上浮(用 margin 错位,避免与 v-reveal 的 transform 冲突) */
|
|
|
|
|
|
.g-triptych {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
padding: 14px 4px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-triptych-item {
|
|
|
|
|
|
aspect-ratio: 10 / 17;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-triptych-mid {
|
|
|
|
|
|
margin-top: -14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 胶片连拍:深色胶片条 + 上下齿孔 + 横滑帧 */
|
|
|
|
|
|
.g-filmstrip {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
background: #26231f;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
padding: 20px 10px;
|
|
|
|
|
|
box-shadow: 0 14px 30px rgba(30, 24, 18, 0.28);
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-filmstrip::before,
|
|
|
|
|
|
.g-filmstrip::after {
|
|
|
|
|
|
content: '';
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 10px;
|
|
|
|
|
|
right: 10px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
background-image: repeating-linear-gradient(90deg, rgba(253, 251, 247, 0.8) 0 10px, transparent 10px 20px);
|
|
|
|
|
|
border-radius: 1px;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-filmstrip::before { top: 6px; }
|
|
|
|
|
|
.g-filmstrip::after { bottom: 6px; }
|
|
|
|
|
|
.g-filmstrip-track {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
scroll-snap-type: x mandatory;
|
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
|
scrollbar-width: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-filmstrip-frame {
|
|
|
|
|
|
flex: 0 0 76%;
|
|
|
|
|
|
min-width: 76%;
|
|
|
|
|
|
aspect-ratio: 4 / 5;
|
|
|
|
|
|
scroll-snap-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 扇形叠影:五张卡绝对定位于底部枢轴,入场后展开成扇 */
|
|
|
|
|
|
.g-fan {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 340px;
|
|
|
|
|
|
margin-inline: auto;
|
|
|
|
|
|
aspect-ratio: 1 / 1.04;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-fan-item {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
bottom: 3%;
|
|
|
|
|
|
width: 56%;
|
|
|
|
|
|
aspect-ratio: 3 / 4;
|
|
|
|
|
|
transform-origin: 50% 94%;
|
|
|
|
|
|
transform: translateX(-50%) rotate(0deg);
|
|
|
|
|
|
transition: transform 0.9s cubic-bezier(0.34, 1.45, 0.5, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 入场(容器 reveal--in)后按序展开 */
|
|
|
|
|
|
.reveal--in .g-fan-item:nth-child(1) { transform: translateX(-50%) rotate(-26deg); transition-delay: 0.08s; }
|
|
|
|
|
|
.reveal--in .g-fan-item:nth-child(2) { transform: translateX(-50%) rotate(-13deg); transition-delay: 0.16s; }
|
|
|
|
|
|
.reveal--in .g-fan-item:nth-child(3) { transform: translateX(-50%) rotate(0deg); transition-delay: 0.24s; }
|
|
|
|
|
|
.reveal--in .g-fan-item:nth-child(4) { transform: translateX(-50%) rotate(13deg); transition-delay: 0.32s; }
|
|
|
|
|
|
.reveal--in .g-fan-item:nth-child(5) { transform: translateX(-50%) rotate(26deg); transition-delay: 0.4s; }
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
|
.g-fan-item { transition: none; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 杂志拼贴:横竖交错三行 grid */
|
|
|
|
|
|
.g-mosaic {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
|
|
grid-template-rows: repeat(3, 1fr);
|
|
|
|
|
|
grid-template-areas: 'a b b' 'c c d' 'e f f';
|
|
|
|
|
|
gap: 7px;
|
|
|
|
|
|
aspect-ratio: 3 / 3.1;
|
|
|
|
|
|
padding: 0 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-mosaic-slot {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
contain: layout paint;
|
|
|
|
|
|
}
|
|
|
|
|
|
.g-mosaic-area-1 { grid-area: a; }
|
|
|
|
|
|
.g-mosaic-area-2 { grid-area: b; }
|
|
|
|
|
|
.g-mosaic-area-3 { grid-area: c; }
|
|
|
|
|
|
.g-mosaic-area-4 { grid-area: d; }
|
|
|
|
|
|
.g-mosaic-area-5 { grid-area: e; }
|
|
|
|
|
|
.g-mosaic-area-6 { grid-area: f; }
|
|
|
|
|
|
.g-mosaic-placeholder {
|
|
|
|
|
|
visibility: hidden;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
background: transparent !important;
|
|
|
|
|
|
border: none !important;
|
|
|
|
|
|
box-shadow: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 09:05:15 +08:00
|
|
|
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
|
|
|
|
</style>
|