初始化

This commit is contained in:
李琦
2026-07-31 09:05:15 +08:00
parent 2a426eebe7
commit 5f00b644cf
31 changed files with 1084 additions and 106 deletions

View File

@@ -0,0 +1,153 @@
<template>
<div class="gallery">
<!-- 标题区 -->
<div v-reveal="'up'" class="text-center mb-5 px-4">
<span class="text-xl text-[#a88c6b] tracking-[0.3em] font-light block mb-2">{{ page.title }}</span>
<span class="text-[9px] text-[#8A8680] uppercase tracking-[0.4em] block mb-4">{{ page.subtitle }}</span>
<span class="text-[12px] text-[#6b6863] font-light leading-relaxed mx-auto max-w-[280px] tracking-widest block whitespace-pre-wrap">{{ page.desc }}</span>
</div>
<!-- 单图(满幅带留白卡纸) -->
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
class="w-full max-w-[300px] mx-auto aspect-[4/5] bg-white p-2 shadow-sm border-[0.5px] border-[#a88c6b]/20 relative mt-6">
<img :src="page.img1" class="w-full h-full object-cover" />
</div>
<!-- 错落双图 -->
<div v-else-if="page.type === 'overlap'" class="relative w-full h-[50vh] mt-4">
<div v-reveal="{ variant: 'left', delay: 100 }" class="absolute top-0 right-4 w-[70%] h-[65%] shadow-md bg-white p-1 z-10 border border-[#a88c6b]/10">
<img :src="page.img1" class="w-full h-full object-cover" />
</div>
<div v-reveal="{ variant: 'right', delay: 300 }" class="absolute bottom-4 left-4 w-[60%] h-[60%] shadow-lg bg-white p-1 z-20 border border-[#a88c6b]/10">
<img :src="page.img2" class="w-full h-full object-cover" />
</div>
</div>
<!-- 一大五小 -->
<div v-else-if="page.type === 'one-large-five-small'"
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20">
<div v-reveal="{ variant: 'scale', delay: 100 }" class="col-span-2 row-span-2 relative overflow-hidden bg-[#Fdfbf7] shadow-sm">
<img :src="page.images[0]" class="w-full h-full object-cover hover:scale-105 transition-transform duration-1000" />
</div>
<div v-for="i in [1,2,3,4,5]" :key="i" v-reveal="{ variant: 'up', delay: i * 80 }" class="relative bg-[#Fdfbf7]">
<img :src="page.images[i]" class="w-full h-full object-cover" />
</div>
</div>
<!-- 九宫格 -->
<div v-else-if="page.type === 'nine-grid'" class="grid grid-cols-3 gap-1.5 px-4 mt-6">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 60 }"
class="aspect-square bg-[#Fdfbf7] relative rounded-sm overflow-hidden">
<img :src="img" class="w-full h-full object-cover hover:scale-105 transition-transform duration-700" />
</div>
</div>
<!-- 瀑布流(错落多列) -->
<div v-else-if="page.type === 'masonry'" class="g-masonry mt-6 px-1">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'up', delay: imgIdx * 50 }" class="g-masonry-item">
<img :src="img" class="w-full block" />
</div>
</div>
<!-- 轮播滑动 -->
<div v-else-if="page.type === 'carousel'" class="relative mt-6">
<div ref="trackRef" class="g-carousel-track no-scrollbar" @scroll="onScroll">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide">
<img :src="img" class="w-full h-full object-cover" />
</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>
<!-- 拍立得拼贴 -->
<div v-else-if="page.type === 'polaroid'" class="g-polaroid mt-6">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 70 }"
class="g-polaroid-item" :style="{ transform: polaroidTransform(imgIdx) }">
<img :src="img" />
</div>
</div>
<!-- 兜底 -->
<div v-else class="text-center text-[#8A8680] text-[12px] mt-6">未识别的版式:{{ page.type }}</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
page: { type: Object, required: true },
})
/* ---------- 轮播状态 ---------- */
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
}
/* ---------- 拍立得旋转/位移 ---------- */
const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5]
const polaroidShift = [0, 10, -8, 6, -10, 8, 4, -6]
function polaroidTransform(i) {
const a = polaroidAngles[i % polaroidAngles.length]
const s = polaroidShift[i % polaroidShift.length]
return `rotate(${a}deg) translateY(${s}px)`
}
</script>
<style scoped>
.gallery { width: 100%; }
/* 瀑布流 */
.g-masonry { column-count: 2; column-gap: 10px; }
.g-masonry-item { break-inside: avoid; margin-bottom: 10px; border-radius: 6px; overflow: hidden; background: #Fdfbf7; box-shadow: 0 1px 4px rgba(0,0,0,0.05); }
.g-masonry-item img { display: block; }
/* 轮播 */
.g-carousel-track {
display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
border-radius: 10px; scrollbar-width: none;
}
.g-carousel-slide { flex: 0 0 100%; min-width: 100%; height: 52vh; scroll-snap-align: center; }
.g-carousel-slide img { width: 100%; height: 100%; object-fit: cover; }
.g-arrow {
position: absolute; top: 50%; transform: translateY(-50%);
width: 34px; height: 34px; border-radius: 9999px; border: none;
background: rgba(255,255,255,0.78); color: #a88c6b; font-size: 20px; line-height: 1;
display: flex; align-items: center; justify-content: center; cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.12); backdrop-filter: blur(6px);
}
.g-prev { left: 8px; }
.g-next { right: 8px; }
.g-dots { display: flex; justify-content: center; gap: 6px; margin-top: 10px; }
.g-dot { width: 7px; height: 7px; border-radius: 9999px; border: none; background: rgba(168,140,107,0.3); padding: 0; cursor: pointer; transition: all 0.3s; }
.g-dot.active { background: #a88c6b; width: 18px; border-radius: 9999px; }
/* 拍立得 */
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
.g-polaroid-item {
width: 42%; background: #fff; padding: 8px 8px 24px; border-radius: 2px;
box-shadow: 0 6px 16px rgba(0,0,0,0.12); transition: transform 0.4s ease;
}
.g-polaroid-item:hover { z-index: 10; }
.g-polaroid-item img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; display: block; }
.no-scrollbar::-webkit-scrollbar { display: none; }
</style>

View File

@@ -1,30 +1,27 @@
import { ref, onUnmounted } from 'vue'
import { ref, computed, onUnmounted } from 'vue'
/**
* 背景音乐控制 composable
* 处理浏览器自动播放限制:首次用户交互后再尝试播放
* 背景音乐控制 composable(支持多曲目)
* - tracks: [{ url, name }] 播放列表
* - activeUrl: 当前选中的曲目地址
* - 处理浏览器自动播放限制:首次用户交互后再尝试播放
*/
export function useAudio(initialUrl = '') {
export function useAudio() {
const isPlaying = ref(false)
const isReady = ref(false)
const tracks = ref([]) // [{ url, name }]
const activeUrl = ref('') // 当前选中曲目 url
let audio = null
let currentUrl = initialUrl
let unlockHandler = null
const setUrl = (url) => {
currentUrl = url
if (audio) {
const wasPlaying = isPlaying.value
audio.pause()
audio.src = url
isPlaying.value = false
if (wasPlaying && url) play()
}
}
const currentName = computed(() => {
const t = tracks.value.find((t) => t.url === activeUrl.value)
return t?.name || '背景音乐'
})
const ensureAudio = () => {
if (audio) return audio
audio = new Audio(currentUrl)
audio = new Audio()
audio.loop = true
audio.volume = 0.5
audio.preload = 'auto'
@@ -34,31 +31,41 @@ export function useAudio(initialUrl = '') {
return audio
}
const tryPlay = async () => {
const playUrl = async (url) => {
if (!url) return
const a = ensureAudio()
if (a.src !== url) a.src = url
activeUrl.value = url
try {
await a.play()
isPlaying.value = true
} catch (e) {
// 被浏览器拦截,等待用户手势
isPlaying.value = false
}
}
const play = async () => {
const a = ensureAudio()
try {
await a.play()
isPlaying.value = true
} catch (e) {
isPlaying.value = false
}
// 设置播放列表与默认选中项
const setTracks = (list, active) => {
tracks.value = Array.isArray(list) ? list.slice().filter((t) => t && t.url) : []
const first = tracks.value[0]?.url || ''
const chosen = active && tracks.value.some((t) => t.url === active) ? active : first
activeUrl.value = chosen
if (audio && isPlaying.value && chosen) playUrl(chosen)
}
// 切换当前播放曲目(若正在播放则立即切换)
const setActive = (url) => {
if (!tracks.value.some((t) => t.url === url)) return
if (isPlaying.value) playUrl(url)
else activeUrl.value = url
}
const play = () => playUrl(activeUrl.value)
const pause = () => {
if (audio) audio.pause()
isPlaying.value = false
}
const toggle = () => {
if (isPlaying.value) pause()
else play()
@@ -91,5 +98,5 @@ export function useAudio(initialUrl = '') {
}
})
return { isPlaying, isReady, play, pause, toggle, tryPlay, armAutoplay, setUrl }
return { isPlaying, isReady, tracks, activeUrl, currentName, setTracks, setActive, play, pause, toggle, armAutoplay }
}

View File

@@ -79,9 +79,8 @@
<a-tab-pane key="visual" tab="视觉与动效">
<a-card :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<a-form-item label="背景音乐链接 (URL)">
<a-input v-model:value="cfg.musicUrl" placeholder="留空则不播放音乐" />
<div class="text-[11px] text-[#8A8680] mt-1">支持 mp3 直链,访客首次交互后自动播放,可随时暂停。</div>
<a-form-item label="背景音乐">
<div class="text-[12px] text-[#8A8680] leading-relaxed">多首背景音乐的上传、试听与「设为播放」请在 <span class="text-[#a88c6b]">背景音乐</span> 标签页管理。</div>
</a-form-item>
<a-form-item label="滚动方式">
<a-radio-group v-model:value="cfg.scrollMode">
@@ -105,6 +104,33 @@
</a-card>
</a-tab-pane>
<!-- 背景音乐 -->
<a-tab-pane key="music" tab="背景音乐">
<a-card :bordered="false" class="!shadow-sm">
<div class="text-[12px] text-[#8A8680] leading-relaxed mb-4">
上传多首背景音乐(支持 mp3 等格式),访客首次交互后自动播放当前曲目,可在前台随时切换或暂停。
</div>
<a-empty v-if="!cfg.musicList.length" description="尚未上传音乐" />
<a-card v-for="(t, i) in cfg.musicList" :key="i" :bordered="false"
class="!shadow-sm mb-3 !border" :class="t.url === cfg.activeMusicUrl ? '!border-[#a88c6b]' : '!border-[#a88c6b]/15'">
<div class="flex items-center gap-3 flex-wrap">
<a-input v-model:value="t.name" placeholder="曲目名称" class="flex-1 min-w-[140px]" />
<span class="text-[10px] text-[#8A8680] flex-1 min-w-[140px] truncate">{{ shortUrl(t.url) }}</span>
<a-button size="small" :type="t.url === cfg.activeMusicUrl ? 'primary' : 'default'"
:class="t.url === cfg.activeMusicUrl ? '!bg-[#a88c6b] !border-[#a88c6b]' : ''"
@click="setActiveMusic(t.url)">
{{ t.url === cfg.activeMusicUrl ? '播放中' : '设为播放' }}
</a-button>
<a-button size="small" danger @click="removeMusic(i)"><i class="fa-solid fa-trash"></i></a-button>
</div>
<audio v-if="t.url" :src="t.url" controls class="w-full mt-3" style="height: 36px;"></audio>
</a-card>
<a-button class="mt-1" @click="openMusicUpload">
<i class="fa-solid fa-upload mr-1 text-[#a88c6b]"></i>上传音乐
</a-button>
</a-card>
</a-tab-pane>
<!-- 相册页 -->
<a-tab-pane key="photos" tab="相册页管理">
<div class="flex justify-between items-center mb-3">
@@ -128,8 +154,23 @@
<a-select-option value="overlap">错落双图</a-select-option>
<a-select-option value="one-large-five-small">一大五小</a-select-option>
<a-select-option value="nine-grid">九宫格</a-select-option>
<a-select-option value="masonry">瀑布流</a-select-option>
<a-select-option value="carousel">轮播滑动</a-select-option>
<a-select-option value="polaroid">拍立得拼贴</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="页面高度">
<div class="flex gap-2 items-center">
<a-select :value="heightSelectValue(page)" @change="(v) => onHeightModeChange(page, v)" class="flex-1">
<a-select-option v-for="opt in HEIGHT_PRESETS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
</a-select>
<a-input-number v-if="heightSelectValue(page) === '__custom__'"
:value="customVh(page)" @change="(v) => onCustomVh(page, v)"
:min="20" :max="200" addon-after="vh" class="w-[150px]" />
</div>
</a-form-item>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="小标题 (英文)">
<a-input v-model:value="page.subtitle" />
</a-form-item>
@@ -150,11 +191,18 @@
<image-field v-model="page.img2" label="图二" @pick="openUpload(u => page.img2 = u)" :uploading="uploading" />
</template>
<template v-else>
<image-field v-for="(img, iidx) in page.images" :key="iidx"
v-model="page.images[iidx]"
:label="`图${iidx+1}`"
@pick="openUpload(u => page.images[iidx] = u)"
:uploading="uploading" />
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative">
<image-field v-model="page.images[iidx]"
:label="`图${iidx+1}`"
@pick="openUpload(u => page.images[iidx] = u)"
:uploading="uploading" />
<button v-if="isFlexible(page.type) && page.images.length > 1"
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-[#a88c6b] text-white text-[10px] leading-none flex items-center justify-center shadow"
@click="removeImage(page, iidx)" title="移除该图">✕</button>
</div>
<button v-if="isFlexible(page.type)"
class="w-[96px] h-[96px] rounded-xl border border-dashed border-[#a88c6b]/40 text-[#a88c6b] text-[11px] flex items-center justify-center hover:border-[#a88c6b] transition-colors"
@click="addImage(page)">+ 添加图片</button>
</template>
</div>
</a-card>
@@ -228,13 +276,14 @@ function defaultConfig() {
hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称',
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
musicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
photoPages: [
{ type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大,还是遇见了你。\n于千万人之中,没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80' },
{ type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初,岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80' },
{ type: 'one-large-five-small', title: '相恋', subtitle: 'ROMANCE', desc: '星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此,还有无数个明天。', images: fill(6) },
{ type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生,风雪是你,平淡是你。', images: fill(9) },
{ type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大,还是遇见了你。\n于千万人之中,没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80', height: '100vh' },
{ type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初,岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80', height: '100vh' },
{ type: 'one-large-five-small', title: '相恋', subtitle: 'ROMANCE', desc: '星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此,还有无数个明天。', images: fill(6), height: '100vh' },
{ type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生,风雪是你,平淡是你。', images: fill(9), height: '100vh' },
],
schedule: [
{ time: '16:00', event: '签到迎宾', desc: 'Welcome' },
@@ -248,20 +297,56 @@ function fill(n) {
return Array.from({ length: n }, () => 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=400&q=80')
}
// 页面高度预设
const HEIGHT_PRESETS = [
{ label: '满屏(整屏)', value: '100vh' },
{ label: '大屏(80%)', value: '80vh' },
{ label: '半屏(60%)', value: '60vh' },
{ label: '自适应(内容高度)', value: 'auto' },
{ label: '自定义', value: '__custom__' },
]
function heightSelectValue(page) {
const match = HEIGHT_PRESETS.find((p) => p.value === page.height)
return match ? match.value : '__custom__'
}
function onHeightModeChange(page, val) {
if (val !== '__custom__') page.height = val
}
function customVh(page) {
const m = /^([\d.]+)vh$/.exec(page.height || '')
return m ? Number(m[1]) : 100
}
function onCustomVh(page, val) {
page.height = (val || 100) + 'vh'
}
function ensurePageShape(p) {
if (p.type === 'single') { p.img1 = p.img1 || ''; delete p.img2; delete p.images }
else if (p.type === 'overlap') { p.img1 = p.img1 || ''; p.img2 = p.img2 || ''; delete p.images }
else {
const need = p.type === 'one-large-five-small' ? 6 : 9
// 固定数量版式:一大五小(6) / 九宫格(9);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
const need = fixed[p.type] || 6
p.images = Array.isArray(p.images) ? p.images : []
while (p.images.length < need) p.images.push('')
if (p.images.length > need) p.images.length = need
if (p.images.length === 0) while (p.images.length < need) p.images.push('')
else if (['one-large-five-small', 'nine-grid'].includes(p.type)) {
while (p.images.length < need) p.images.push('')
if (p.images.length > need) p.images.length = need
}
delete p.img1; delete p.img2
}
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
p.height = p.height || '100vh'
return p
}
// 柔性版式(图片数量可增删)
function isFlexible(type) {
return ['masonry', 'carousel', 'polaroid'].includes(type)
}
function addImage(page) { if (!Array.isArray(page.images)) page.images = []; page.images.push('') }
function removeImage(page, i) { if (page.images.length > 1) page.images.splice(i, 1) }
// 相册页操作
function addPage() { cfg.photoPages.push(ensurePageShape({ type: 'single', title: '新页面', subtitle: 'NEW', desc: '', img1: '' })) }
function removePage(i) { cfg.photoPages.splice(i, 1) }
@@ -270,7 +355,7 @@ function movePage(i, dir) { const j = i + dir; if (j < 0 || j >= cfg.photoPages.
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
function removeSchedule(i) { cfg.schedule.splice(i, 1) }
// 上传
// 上传(图片)
function openUpload(onUrl) {
const input = document.createElement('input')
input.type = 'file'; input.accept = 'image/*'
@@ -289,6 +374,42 @@ function openUpload(onUrl) {
input.click()
}
// 上传(背景音乐,多首)
function openMusicUpload() {
const input = document.createElement('input')
input.type = 'file'; input.accept = 'audio/*'
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return
uploading.value = true
try {
const res = await uploadImage(file) // 后端 /upload 同处理任意文件
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
const url = res.url
if (!cfg.musicList) cfg.musicList = []
cfg.musicList.push({ url, name })
if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
message.success('音乐上传成功')
} catch (err) {
message.error('上传失败')
} finally {
uploading.value = false; e.target.value = ''
}
}
input.click()
}
function setActiveMusic(url) { cfg.activeMusicUrl = url }
function removeMusic(i) {
cfg.musicList.splice(i, 1)
if (cfg.activeMusicUrl === cfg.musicList[i]?.url) {
cfg.activeMusicUrl = cfg.musicList[0]?.url || ''
}
}
function shortUrl(url) {
if (!url) return ''
const parts = url.split('/')
return parts[parts.length - 1] || url
}
// 保存
async function onSave() {
saving.value = true
@@ -337,6 +458,11 @@ onMounted(async () => {
const res = await getConfig()
if (res.data && res.data !== '{}') {
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
// 兼容旧版单链接 musicUrl
if (loaded.musicUrl && !(loaded.musicList && loaded.musicList.length)) {
loaded.musicList = [{ url: loaded.musicUrl, name: '背景音乐' }]
loaded.activeMusicUrl = loaded.musicUrl
}
Object.assign(cfg, loaded)
cfg.photoPages = (cfg.photoPages || []).map(ensurePageShape)
}

View File

@@ -1,14 +1,20 @@
<template>
<div class="wrapper relative w-full h-[100vh] bg-[#Fdfbf7] overflow-hidden text-[#2c2c2c]">
<!-- 入场揭幕帘幕 -->
<div v-show="showIntro" @click="skipIntro"
class="curtain fixed inset-0 z-[100] bg-gradient-to-b from-[#a88c6b] to-[#8a704f] flex flex-col items-center justify-center cursor-pointer">
<div class="curtain-seal text-center text-white">
<div class="text-[10px] tracking-[0.6em] uppercase opacity-80 mb-4">Wedding Invitation</div>
<div class="text-3xl font-light tracking-[0.2em]">{{ data.groom }} <span class="italic font-serif mx-2">&amp;</span> {{ data.bride }}</div>
<div class="w-10 h-[1px] bg-white/60 mx-auto mt-5"></div>
<div class="text-[11px] tracking-[0.4em] mt-5 opacity-70">点击进入</div>
<!-- 入场揭幕帘幕(双开门 + 印章徽标) -->
<div v-show="showIntro" class="curtain-root" :class="{ 'is-opening': introOpening }" @click="openInvitation">
<div class="curtain-bg" :style="curtainBg"></div>
<div class="curtain-veil"></div>
<div class="curtain-door curtain-door-left"></div>
<div class="curtain-door curtain-door-right"></div>
<div class="curtain-emblem">
<div class="emblem-card">
<span class="emblem-label">WEDDING INVITATION</span>
<div class="emblem-seal">❀</div>
<div class="emblem-names">{{ data.groom }} <span class="amp">&amp;</span> {{ data.bride }}</div>
<div class="emblem-date">{{ data.date }}</div>
<div class="emblem-hint"><span class="dot"></span> 点击开启请柬</div>
</div>
</div>
</div>
@@ -29,11 +35,31 @@
<div class="absolute top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
<!-- 右上角控制台 -->
<div class="absolute top-6 right-5 z-50 flex flex-col gap-4">
<div @click="audio.toggle()" class="control-btn cursor-pointer" :class="{'rotate-spin': audio.isPlaying.value}">
<img v-if="audio.isPlaying.value" src="https://api.iconify.design/lucide:music.svg?color=%23a88c6b" class="w-4 h-4" />
<img v-else src="https://api.iconify.design/lucide:volume-x.svg?color=%23a88c6b" class="w-4 h-4" />
<div class="absolute top-6 right-5 z-50 flex flex-col items-end gap-4">
<!-- 音乐控制 -->
<div class="music-wrap">
<div class="flex flex-col gap-3 items-end">
<div @click="audio.toggle()" class="control-btn cursor-pointer" :class="{ 'rotate-spin': audio.isPlaying.value }">
<img v-if="audio.isPlaying.value" src="https://api.iconify.design/lucide:music.svg?color=%23a88c6b" class="w-4 h-4" />
<img v-else src="https://api.iconify.design/lucide:volume-x.svg?color=%23a88c6b" class="w-4 h-4" />
</div>
<div v-if="audio.tracks.value.length > 1" @click="showTrackList = !showTrackList" class="control-btn cursor-pointer">
<img src="https://api.iconify.design/lucide:list-music.svg?color=%23a88c6b" class="w-4 h-4" />
</div>
</div>
<!-- 曲目切换弹层 -->
<div v-if="showTrackList && audio.tracks.value.length" class="track-pop">
<div class="track-pop-title">选择曲目</div>
<div v-for="t in audio.tracks.value" :key="t.url"
class="track-item" :class="{ active: t.url === audio.activeUrl.value }"
@click="selectTrack(t.url)">
<span class="eq" v-if="t.url === audio.activeUrl.value && audio.isPlaying.value"><i></i><i></i><i></i></span>
<span class="tname">{{ t.name }}</span>
<span class="tcheck" v-if="t.url === audio.activeUrl.value">✓</span>
</div>
</div>
</div>
<div @click="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300" :class="isAutoScroll ? 'bg-[#a88c6b]' : 'bg-white/60 shadow-sm border-[#a88c6b]/30'">
<img v-if="isAutoScroll" src="https://api.iconify.design/lucide:pause.svg?color=%23ffffff" class="w-4 h-4" />
<img v-else src="https://api.iconify.design/lucide:play.svg?color=%23a88c6b" class="w-4 h-4 ml-0.5" />
@@ -94,54 +120,17 @@
</div>
</div>
<!-- 3-6. 画廊循环 -->
<div v-for="(page, pIdx) in data.photoPages" :key="pIdx" class="page-section p-6 relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<!-- 3-N. 画廊循环 -->
<div v-for="(page, pIdx) in data.photoPages" :key="pIdx" class="page-section p-6 relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''" :style="pageHeightStyle(page)">
<div class="absolute inset-0 bg-white/40 backdrop-blur-[2px] -z-10"></div>
<div class="absolute top-0 left-0 w-24 h-24 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
<div class="absolute bottom-0 right-0 w-24 h-24 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
<div class="w-full h-full flex flex-col justify-center py-6 relative z-10">
<div v-reveal="'up'" class="text-center mb-4 px-4">
<span class="text-xl text-[#a88c6b] tracking-[0.3em] font-light block mb-2">{{ page.title }}</span>
<span class="text-[9px] text-[#8A8680] uppercase tracking-[0.4em] block mb-4">{{ page.subtitle }}</span>
<span class="text-[12px] text-[#6b6863] font-light leading-relaxed mx-auto max-w-[280px] tracking-widest block whitespace-pre-wrap">{{ page.desc }}</span>
</div>
<!-- 单图 -->
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }" class="w-full max-w-[300px] mx-auto aspect-[4/5] bg-white p-2 shadow-sm border-[0.5px] border-[#a88c6b]/20 relative mt-8">
<img :src="page.img1" class="w-full h-full object-cover" />
</div>
<!-- 错落双图 -->
<div v-if="page.type === 'overlap'" class="relative w-full h-[50vh] mt-4">
<div v-reveal="{ variant: 'left', delay: 100 }" class="absolute top-0 right-4 w-[70%] h-[65%] shadow-md bg-white p-1 z-10 border border-[#a88c6b]/10">
<img :src="page.img1" class="w-full h-full object-cover" />
</div>
<div v-reveal="{ variant: 'right', delay: 300 }" class="absolute bottom-4 left-4 w-[60%] h-[60%] shadow-lg bg-white p-1 z-20 border border-[#a88c6b]/10">
<img :src="page.img2" class="w-full h-full object-cover" />
</div>
</div>
<!-- 一大五小 -->
<div v-if="page.type === 'one-large-five-small'" class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20">
<div v-reveal="{ variant: 'scale', delay: 100 }" class="col-span-2 row-span-2 relative overflow-hidden bg-[#Fdfbf7] shadow-sm">
<img :src="page.images[0]" class="w-full h-full object-cover hover:scale-105 transition-transform duration-1000" />
</div>
<div v-for="i in [1,2,3,4,5]" :key="i" v-reveal="{ variant: 'up', delay: i * 80 }" class="relative bg-[#Fdfbf7]">
<img :src="page.images[i]" class="w-full h-full object-cover" />
</div>
</div>
<!-- 九宫格 -->
<div v-if="page.type === 'nine-grid'" class="grid grid-cols-3 gap-1.5 px-4 mt-6">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 60 }" class="aspect-square bg-[#Fdfbf7] relative rounded-sm overflow-hidden">
<img :src="img" class="w-full h-full object-cover hover:scale-105 transition-transform duration-700" />
</div>
</div>
<PhotoGallery :page="page" />
</div>
</div>
<!-- 7. 婚礼流程 -->
<!-- 婚礼流程 -->
<div class="page-section bg-transparent px-6 z-10 justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="absolute inset-0 bg-[#Fdfbf7]/60 backdrop-blur-[2px] -z-10"></div>
<div v-reveal="'up'" class="text-center mb-14">
@@ -163,7 +152,7 @@
</div>
</div>
<!-- 8. 尾页 -->
<!-- 尾页 -->
<div class="page-section p-6 flex flex-col items-center justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="absolute top-10 bottom-10 left-6 right-6 border-[0.5px] border-[#a88c6b]/40 rounded-t-[10rem] z-0 bg-[#Fdfbf7]/30"></div>
<div class="w-full flex flex-col items-center z-10 relative py-10">
@@ -223,12 +212,14 @@
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { getConfig, submitRsvp } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
import PhotoGallery from '@/components/PhotoGallery.vue'
const DEFAULTS = {
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true, musicUrl: '',
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
musicList: [], activeMusicUrl: '',
}
const data = ref({
@@ -249,7 +240,7 @@ const data = ref({
const audio = useAudio()
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSelectOpen = ref(false), isSubmitted = ref(false)
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0), parallax = ref(0)
const showIntro = ref(false)
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false)
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
@@ -260,6 +251,14 @@ const petalColors = ['#f7d9e0', '#fbeee3', '#f3c6d3', '#efd9c4', '#f9e7d6']
const bubbles = Array.from({ length: 15 }).map((_, i) => ({ id: i, left: Math.random() * 100 + '%', color: loveColors[i % 4], size: 14 + Math.random() * 10, opacity: 0.2, floatDuration: 14 + Math.random() * 10 + 's', swayDuration: 4 + Math.random() * 2 + 's' }))
const petals = Array.from({ length: 18 }).map((_, i) => ({ id: i, left: Math.random() * 100 + '%', size: 10 + Math.random() * 14, opacity: 0.4 + Math.random() * 0.4, color: petalColors[i % petalColors.length], duration: 9 + Math.random() * 8 + 's', delay: Math.random() * 10 + 's', rotate: Math.random() * 360 }))
const curtainBg = computed(() => ({ backgroundImage: `url(${data.value.heroImg})` }))
// 相册页页面高度:支持 100vh / 80vh / 60vh / auto / 自定义 vh
const pageHeightStyle = (page) => {
const h = page.height || '100vh'
return h === 'auto' ? { minHeight: 'auto' } : { minHeight: h }
}
const selectRsvpCount = (val) => { rsvpForm.guest_count = val; isSelectOpen.value = false }
const closeDrawer = () => { isDrawerOpen.value = false; isSelectOpen.value = false }
@@ -311,7 +310,13 @@ const toggleAutoScroll = () => {
if (isAutoScroll.value && el && el.scrollTop >= el.scrollHeight - el.clientHeight - 10) el.scrollTop = 0
}
const skipIntro = () => { showIntro.value = false }
// 入场揭幕:开门动画
const openInvitation = () => {
if (introOpening.value) return
introOpening.value = true
introTimer = setTimeout(() => { showIntro.value = false }, 1000)
}
const selectTrack = (url) => { audio.setActive(url); showTrackList.value = false }
const handleSubmitRsvp = async () => {
if (!rsvpForm.name.trim()) return alert('请填写姓名')
@@ -327,14 +332,19 @@ onMounted(async () => {
const res = await getConfig()
if (res.data && res.data !== '{}') {
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
// 兼容旧版单链接 musicUrl
if (loaded.musicUrl && !(loaded.musicList && loaded.musicList.length)) {
loaded.musicList = [{ url: loaded.musicUrl, name: '背景音乐' }]
loaded.activeMusicUrl = loaded.musicUrl
}
data.value = { ...data.value, ...DEFAULTS, ...loaded }
}
} catch (e) { /* 使用默认配置 */ }
if (data.value.musicUrl) audio.setUrl(data.value.musicUrl)
audio.setTracks(data.value.musicList, data.value.activeMusicUrl)
if (data.value.introEnabled) {
showIntro.value = true
introTimer = setTimeout(() => (showIntro.value = false), 3200)
introTimer = setTimeout(() => openInvitation(), 3600)
audio.armAutoplay()
}
@@ -362,4 +372,64 @@ onUnmounted(() => {
/* 花瓣形状 */
.petal { border-radius: 0 100% 0 100%; }
.heart-bubble { animation: heartFloat var(--float-duration, 18s) linear infinite, heartSway var(--sway-duration, 5s) ease-in-out infinite alternate; position: absolute; bottom: 0; }
/* ---------- 入场揭幕 ---------- */
.curtain-root { position: fixed; inset: 0; z-index: 100; overflow: hidden; cursor: pointer; font-family: "PingFang SC", sans-serif; }
.curtain-bg {
position: absolute; inset: -24px; background-size: cover; background-position: center;
filter: blur(14px) brightness(0.5) saturate(1.1); transform: scale(1.12);
}
.curtain-veil { position: absolute; inset: 0; background: radial-gradient(circle at 50% 42%, rgba(0,0,0,0.15), rgba(0,0,0,0.5)); }
.curtain-door {
position: absolute; top: 0; bottom: 0; width: 50%; z-index: 2;
background: linear-gradient(160deg, #b89a78 0%, #a88c6b 45%, #8a704f 100%);
box-shadow: inset 0 0 60px rgba(0,0,0,0.25);
transition: transform 1s cubic-bezier(0.76, 0, 0.24, 1);
}
.curtain-door::after { content: ''; position: absolute; top: 0; bottom: 0; width: 1px; background: rgba(255,255,255,0.18); }
.curtain-door-left { left: 0; }
.curtain-door-right { right: 0; }
.curtain-door-right::after { right: 0; }
.curtain-root.is-opening .curtain-door-left { transform: translateX(-100%); }
.curtain-root.is-opening .curtain-door-right { transform: translateX(100%); }
.curtain-emblem {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 3;
transition: opacity 0.7s ease, transform 0.9s ease; animation: emblemIn 1s ease both;
}
.curtain-root.is-opening .curtain-emblem { opacity: 0; transform: scale(1.3); }
.emblem-card { text-align: center; color: #fff; padding: 30px 34px; border-radius: 18px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);
box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
.emblem-label { display: block; letter-spacing: 0.55em; font-size: 10px; opacity: 0.85; text-transform: uppercase; }
.emblem-seal { width: 66px; height: 66px; margin: 16px auto; border: 1px solid rgba(255,255,255,0.6); border-radius: 9999px;
display: flex; align-items: center; justify-content: center; font-size: 24px; color: #fff; }
.emblem-names { font-family: "Noto Serif SC", serif; font-size: 26px; letter-spacing: 0.15em; font-weight: 300; }
.emblem-names .amp { font-style: italic; opacity: 0.85; margin: 0 6px; }
.emblem-date { font-size: 11px; letter-spacing: 0.3em; opacity: 0.8; margin-top: 10px; }
.emblem-hint { margin-top: 22px; font-size: 11px; letter-spacing: 0.3em; opacity: 0.85;
display: flex; align-items: center; justify-content: center; gap: 8px; }
.emblem-hint .dot { width: 6px; height: 6px; border-radius: 9999px; background: #fff; animation: hintPulse 1.6s infinite; }
/* ---------- 音乐曲目弹层 ---------- */
.music-wrap { position: relative; }
.track-pop {
position: absolute; right: 0; bottom: 50px; width: 200px; background: rgba(255,255,255,0.95);
border: 1px solid rgba(168,140,107,0.25); border-radius: 14px; box-shadow: 0 12px 30px rgba(0,0,0,0.18);
padding: 8px; backdrop-filter: blur(12px); z-index: 60;
}
.track-pop-title { font-size: 10px; color: #a88c6b; letter-spacing: 0.2em; padding: 4px 8px 6px; }
.track-item { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 10px; cursor: pointer; transition: background 0.2s; }
.track-item:hover { background: rgba(168,140,107,0.1); }
.track-item.active { background: rgba(168,140,107,0.16); color: #a88c6b; }
.track-item .tname { font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track-item .tcheck { font-size: 12px; color: #a88c6b; }
.eq { display: inline-flex; align-items: flex-end; gap: 2px; width: 14px; }
.eq i { width: 3px; background: #a88c6b; border-radius: 2px; animation: eqJump 0.9s ease-in-out infinite; }
.eq i:nth-child(1) { height: 8px; animation-delay: 0s; }
.eq i:nth-child(2) { height: 12px; animation-delay: 0.2s; }
.eq i:nth-child(3) { height: 6px; animation-delay: 0.4s; }
@keyframes eqJump { 0%,100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
@keyframes emblemIn { from { opacity: 0; transform: scale(0.85) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
@keyframes hintPulse { 0%,100% { transform: scale(1); opacity: 0.6; } 50% { transform: scale(1.7); opacity: 1; } }
</style>