fix: 升级了一些视觉

This commit is contained in:
2026-08-12 13:52:54 +08:00
parent 1f9ac09829
commit 5fb0f9a1a7
27 changed files with 5032 additions and 826 deletions

View File

@@ -51,13 +51,13 @@ const { toasts, dismiss } = useToast()
padding: 12px 14px;
border-radius: 16px;
background: rgba(253, 251, 247, 0.96);
border: 0.5px solid rgba(168, 140, 107, 0.35);
border: 0.5px solid rgba(var(--c-primary-rgb), 0.35);
box-shadow: 0 12px 32px rgba(92, 74, 53, 0.16);
backdrop-filter: blur(8px);
color: #5c4a35;
}
.app-toast.is-success {
border-color: rgba(168, 140, 107, 0.5);
border-color: rgba(var(--c-primary-rgb), 0.5);
}
.app-toast.is-error {
border-color: rgba(196, 120, 110, 0.55);
@@ -73,8 +73,8 @@ const { toasts, dismiss } = useToast()
justify-content: center;
margin-top: 1px;
font-size: 11px;
background: rgba(168, 140, 107, 0.14);
color: #a88c6b;
background: rgba(var(--c-primary-rgb), 0.14);
color: var(--c-primary);
}
.app-toast.is-error .app-toast-icon {
background: rgba(196, 120, 110, 0.16);

View File

@@ -0,0 +1,143 @@
<template>
<div class="bsp">
<div v-for="group in BORDER_STYLE_GROUPS" :key="group.key" class="bsp-group">
<div class="bsp-group-title">{{ group.label }}</div>
<div class="bsp-grid">
<button
v-for="b in group.items"
:key="b.value"
type="button"
class="bsp-cell"
:class="{ active: modelValue === b.value }"
:title="b.label"
@click="$emit('update:modelValue', b.value)"
>
<span class="bsp-thumb-box" :style="{ height: thumbHeight(b) / 2 + 'px' }">
<span
class="bsp-thumb"
:class="{ 'gf-shape-shadow': b.shaped }"
:style="{ height: thumbHeight(b) + 'px' }"
>
<span class="bsp-frame" :class="borderClass(b.value)">
<img :src="sample" alt="" loading="lazy" />
</span>
</span>
</span>
<span class="bsp-name">{{ b.label }}</span>
<i v-if="modelValue === b.value" class="bsp-check fa-solid fa-check"></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { BORDER_STYLE_GROUPS, borderClass } from '@/composables/borderStyles'
defineProps({
modelValue: { type: String, default: 'mat' },
/** 缩略示例图 */
sample: {
type: String,
default: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=200&q=80',
},
})
defineEmits(['update:modelValue'])
/** 缩略图按 2 倍渲染再 scale(0.5),保证固定像素边框细节比例正确;高度随样式推荐宽高比 */
const THUMB_W = 120
function thumbHeight(b) {
const parts = String(b.ratio || '4 / 5').split('/')
const w = parseFloat(parts[0]) || 4
const h = parseFloat(parts[1]) || 5
return Math.round((THUMB_W * h) / w)
}
</script>
<style scoped>
.bsp {
display: flex;
flex-direction: column;
gap: 12px;
}
.bsp-group-title {
font-size: 11px;
color: var(--c-primary);
letter-spacing: 0.12em;
margin-bottom: 6px;
}
.bsp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
gap: 8px;
}
.bsp-cell {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 6px;
padding: 10px 4px 7px;
border: 1px solid rgba(var(--c-primary-rgb), 0.14);
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.bsp-cell:hover {
border-color: rgba(var(--c-primary-rgb), 0.4);
background: #fbf9f4;
}
.bsp-cell.active {
border-color: var(--c-primary);
background: var(--c-bg);
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.18);
}
.bsp-thumb-box {
display: flex;
justify-content: center;
width: 60px;
}
.bsp-thumb {
flex: none;
width: 120px;
transform: scale(0.5);
transform-origin: top center;
}
.bsp-frame {
display: block;
width: 100%;
height: 100%;
}
/* 缩略图太窄,邮戳 SVG 会压住 POST CARD 字样,仅保留字样 */
.bsp-frame.gf-postcard::after {
display: none;
}
.bsp-name {
font-size: 10px;
color: #6b6863;
letter-spacing: 0.05em;
white-space: nowrap;
}
.bsp-cell.active .bsp-name {
color: var(--c-primary);
font-weight: 600;
}
.bsp-check {
position: absolute;
top: -6px;
right: -6px;
z-index: 1;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--c-primary);
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(120, 90, 60, 0.3);
}
</style>

View File

@@ -10,25 +10,22 @@
v-for="row in rows"
:key="row.id"
class="danmaku-item"
:class="{
'danmaku-item--grad': row.isGradient,
'danmaku-item--grad-light': row.isGradient && row.textLight,
'danmaku-item--grad-dark': row.isGradient && !row.textLight,
}"
:class="{ 'danmaku-item--grad': row.isGradient }"
:style="row.style"
@animationend="onEnded(row.id)"
>
<span class="danmaku-name" :style="{ color: row.color }">{{ row.name }}</span>
<span class="danmaku-sep" :style="{ color: row.color }"></span>
<span class="danmaku-text" :style="{ color: row.color }">{{ row.content }}</span>
<!-- 渐变款颜色落在文字上clip 渐变字底子是通透白纱 -->
<span
v-if="showTime && row.timeLabel"
class="danmaku-time"
:class="{
'danmaku-time--on-light': row.isGradient && row.textLight,
'danmaku-time--on-dark': row.isGradient && !row.textLight,
}"
>{{ row.timeLabel }}</span>
v-if="row.textGradient"
class="danmaku-body danmaku-body--grad"
:style="{ backgroundImage: row.textGradient }"
><span class="danmaku-name">{{ row.name }}</span><span class="danmaku-sep"></span><span class="danmaku-text">{{ row.content }}</span></span>
<template v-else>
<span class="danmaku-name" :style="{ color: row.color }">{{ row.name }}</span>
<span class="danmaku-sep" :style="{ color: row.color }"></span>
<span class="danmaku-text" :style="{ color: row.color }">{{ row.content }}</span>
</template>
<span v-if="showTime && row.timeLabel" class="danmaku-time">{{ row.timeLabel }}</span>
</div>
</div>
</template>
@@ -173,7 +170,6 @@ const buildRow = (item, { force = false } = {}) => {
const totalDelay = delay + slot.extraDelay
// 轨道分布在当前弹幕层内部(由 area 控制层高度)
const topPct = TRACKS <= 1 ? 50 : 12 + (slot.track / (TRACKS - 1)) * 70
const isGradient = typeof tint.background === 'string' && tint.background.includes('gradient')
const timeLabel = force
? '刚刚'
@@ -184,8 +180,8 @@ const buildRow = (item, { force = false } = {}) => {
name: item.name,
content: item.content,
color: resolveDanmakuColor(item.color),
textLight: !!tint.textLight,
isGradient,
isGradient: !!tint.textGradient,
textGradient: tint.textGradient || '',
timeLabel,
style: {
top: `${topPct}%`,
@@ -377,7 +373,7 @@ defineExpose({ refresh: fetchList, pushItem })
white-space: nowrap;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid rgba(168, 140, 107, 0.28);
border: 1px solid rgba(var(--c-primary-rgb), 0.28);
box-shadow: 0 4px 14px rgba(88, 68, 42, 0.08);
font-size: 12px;
letter-spacing: 0.06em;
@@ -389,14 +385,18 @@ defineExpose({ refresh: fetchList, pushItem })
max-width: none;
}
.danmaku-item--grad {
backdrop-filter: saturate(1.2) blur(8px);
backdrop-filter: saturate(1.15) blur(7px);
font-weight: 500;
}
.danmaku-item--grad-light {
text-shadow: 0 1px 2px rgba(20, 10, 20, 0.45), 0 0 1px rgba(0, 0, 0, 0.2);
/* 渐变字:颜色裁切进文字,底子保持通透 */
.danmaku-body--grad {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.danmaku-item--grad-dark {
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
.danmaku-body--grad .danmaku-sep,
.danmaku-body--grad .danmaku-text {
opacity: 1;
}
.danmaku-name { font-weight: 600; }
.danmaku-sep { opacity: 0.9; }
@@ -407,12 +407,6 @@ defineExpose({ refresh: fetchList, pushItem })
font-size: 10px;
letter-spacing: 0.04em;
}
.danmaku-time--on-light {
color: rgba(255, 255, 255, 0.88);
}
.danmaku-time--on-dark {
color: rgba(74, 52, 32, 0.72);
}
@keyframes danmaku-fly {
from { transform: translateX(0); }
to { transform: translateX(calc(-100vw - 100%)); }

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,17 @@
<template>
<div
class="relative w-[96px] h-[96px] rounded-xl border border-dashed border-[#a88c6b]/40 bg-[#Fdfbf7] overflow-hidden cursor-pointer hover:border-[#a88c6b] transition-colors group"
class="relative w-[96px] h-[96px] rounded-xl border border-dashed border-[var(--c-primary)]/40 bg-[var(--c-paper)] overflow-hidden cursor-pointer hover:border-[var(--c-primary)] transition-colors group"
@click="$emit('pick')"
>
<img v-if="modelValue" :src="modelValue" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex flex-col items-center justify-center text-[#a88c6b]/70 gap-1">
<div v-else class="w-full h-full flex flex-col items-center justify-center text-[var(--c-primary)]/70 gap-1">
<i class="fa-solid fa-image text-lg"></i>
<span class="text-[10px]">{{ placeholder }}</span>
</div>
<div class="absolute inset-0 bg-black/35 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-[10px]">
<i class="fa-solid fa-images mr-1"></i>{{ uploading ? '上传中' : '选择图片' }}
</div>
<div v-if="label" class="absolute left-1 bottom-1 bg-white/80 text-[#a88c6b] text-[9px] px-1 rounded">{{ label }}</div>
<div v-if="label" class="absolute left-1 bottom-1 bg-white/80 text-[var(--c-primary)] text-[9px] px-1 rounded">{{ label }}</div>
</div>
</template>

View File

@@ -8,7 +8,7 @@
@cancel="$emit('update:open', false)"
>
<div class="mp-toolbar">
<a-button type="primary" class="!bg-[#a88c6b]" :loading="uploading" @click="triggerUpload">
<a-button type="primary" class="!bg-[var(--c-primary)]" :loading="uploading" @click="triggerUpload">
<i class="fa-solid fa-cloud-arrow-up mr-1"></i>上传新图
</a-button>
<a-button :loading="loading" @click="reloadFirstPage">
@@ -153,12 +153,12 @@ watch(
}
.mp-hint {
font-size: 12px;
color: #8a8680;
color: var(--c-muted);
margin-left: auto;
}
.mp-empty {
text-align: center;
color: #8a8680;
color: var(--c-muted);
padding: 48px 12px;
font-size: 13px;
}
@@ -170,8 +170,8 @@ watch(
}
.mp-item {
appearance: none;
border: 1px solid rgba(168, 140, 107, 0.28);
background: #fdfbf7;
border: 1px solid rgba(var(--c-primary-rgb), 0.28);
background: var(--c-paper);
border-radius: 10px;
padding: 0;
overflow: hidden;
@@ -181,21 +181,21 @@ watch(
}
.mp-item:hover,
.mp-item.active {
border-color: #a88c6b;
box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.2);
border-color: var(--c-primary);
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.2);
}
.mp-item img {
display: block;
width: 100%;
height: 88px;
object-fit: cover;
background: #f0ebe3;
background: var(--c-bg-deep);
}
.mp-name {
display: block;
padding: 4px 6px;
font-size: 10px;
color: #8a8680;
color: var(--c-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,17 +1,26 @@
<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>
<!-- 标题区完整模板主题用统一章节头风格包保留经典排版 -->
<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>
</div>
<!-- 单图默认居中卡幅 / 可配置宽度铺满 -->
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
<div v-if="page.type === 'single'" v-reveal="{ variant: singleRevealVariant, 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)" />
<span v-if="showPostmark" class="gf-postmark" aria-hidden="true"></span>
</div>
</div>
@@ -197,8 +206,13 @@
</div>
</div>
<!-- 爱心拼接绝对定位心形轮廓 + 串行挂载 -->
<div v-else-if="page.type === 'heart-collage'" ref="multiRootRef" class="g-heart mt-6">
<!-- 爱心拼接绝对定位心形轮廓 + 串行挂载拼合完成后心跳一次并飘散小心心 -->
<div
v-else-if="page.type === 'heart-collage'"
ref="multiRootRef"
class="g-heart mt-6"
:class="{ 'g-heart-done': heartDone }"
>
<div
v-for="(slot, imgIdx) in HEART_SLOTS"
:key="imgIdx"
@@ -211,6 +225,7 @@
v-reveal="{ variant: heartReveal(imgIdx), immediate: true }"
class="absolute inset-0 g-heart-cell"
:class="frameClass"
:style="heartCellShape(imgIdx)"
>
<img
:src="displayAt(imgIdx)"
@@ -221,17 +236,81 @@
/>
</div>
</div>
<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 class="text-center text-[#8A8680] text-[12px] mt-6">未识别的版式{{ page.type }}</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>
</div>
<div v-else class="text-center text-[var(--c-muted)] 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 { borderClass, borderStyleMeta } from '@/composables/borderStyles'
import { createSequentialMount } from '@/composables/useSequentialMount'
import { HEART_SLOTS, heartSlotStyle, heartFocusCssAt } from '@/composables/heartCollage'
import { HEART_SLOTS, heartSlotStyle, heartFocusCssAt, heartCellShape } from '@/composables/heartCollage'
import SectionHeading from '@/components/themes/SectionHeading.vue'
const NINE_STEP_MS = 300
const NINE_SETTLE_MS = 400
@@ -264,12 +343,17 @@ const SEQUENTIAL_SELF_TYPES = new Set([
'nine-grid',
'one-large-five-small',
'heart-collage',
'mosaic',
])
const props = defineProps({
page: { type: Object, required: true },
/** 九宫格:开启串行 v-reveal关闭则一次展示 */
nineGridAnimate: { type: Boolean, default: false },
/** 完整模板主题magazine/newspaper/pixel/zen标题区换统一章节头 */
layoutTheme: { type: Boolean, default: false },
/** 章节序号(完整模板的编号章节头用) */
pageIndex: { type: Number, default: 0 },
})
const emit = defineEmits(['preview-image', 'ready'])
@@ -475,7 +559,7 @@ const runSequentialByType = async () => {
await runOlfsSequential()
return
}
if (type === 'masonry' || type === 'polaroid' || type === 'heart-collage') {
if (type === 'masonry' || type === 'polaroid' || type === 'heart-collage' || type === 'mosaic') {
await runMultiSequential()
}
}
@@ -592,15 +676,29 @@ function getRevealConfig(imgIdx) {
}
}
const singleWrapClass = computed(() =>
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
/** 当前边框元数据(分组/是否异形裁切/推荐宽高比) */
const singleStyleMeta = computed(() => borderStyleMeta(props.page.borderStyle))
/** 明信片·异形/横幅风景分组用飞入入场,其余保持缩放入场 */
const singleRevealVariant = computed(() =>
['postcard', 'scenic'].includes(singleStyleMeta.value?.group) ? 'postcard' : 'scale'
)
/** 邮戳盖章:仅 明信片/邮票 两种边框叠加 */
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(' '))
const singleWrapStyle = computed(() => {
const ratio = singleStyleMeta.value?.ratio || '4 / 5'
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 props.page.singleKeepRatio ? bleed : { ...bleed, aspectRatio: ratio }
}
return { aspectRatio: '4 / 5' }
return { aspectRatio: ratio }
})
const singleFrameClass = computed(() =>
frameClass.value + (props.page.singleWidth === 'full' && props.page.singleKeepRatio ? ' gf-natural' : '')
@@ -644,6 +742,11 @@ function heartReveal(i) {
return 'up'
}
/** 爱心拼合完成:触发一次心跳 + 小心心飘散 */
const heartDone = computed(() =>
props.page?.type === 'heart-collage' && shownCount.value >= HEART_SLOTS.length
)
</script>
<style scoped>
@@ -753,15 +856,15 @@ function heartReveal(i) {
.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;
background: rgba(255,255,255,0.78); color: var(--c-primary); 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-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; }
/* 爱心拼接:绝对定位块状心形 + 淡囍底纹 */
.g-heart {
@@ -816,6 +919,7 @@ function heartReveal(i) {
}
.g-heart-cell {
overflow: hidden;
border-radius: 5px;
transition: transform 0.35s ease, box-shadow 0.35s ease;
}
@media (hover: hover) {
@@ -832,6 +936,41 @@ function heartReveal(i) {
display: block;
}
/* 拼合完成整颗心轻跳两下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; }
}
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
.g-polaroid-slot {
width: 42%;
@@ -849,5 +988,122 @@ function heartReveal(i) {
.g-polaroid-item:hover { z-index: 10; }
.g-polaroid-item img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; display: block; }
/* 通用:新版式内图片铺满裁切 */
.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;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
</style>