初始化

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

2
.idea/vcs.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/vite-tailwindcss" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,32 @@
# 2026-07-31 婚礼请柬优化(第二版)
完成了用户提出的三点需求:
1. 背景音乐支持上传多首,后台选择当前播放曲目,前台仍可切换/暂停。
2. 入场揭幕帘幕 UI 美化:双开门+毛玻璃印章徽标+背景图。
3. 相册布局新增 瀑布流(masonry)、轮播(carousel)、拍立得(polaroid),并保留/优化单图布局;后台对柔性版式支持图片增删。
关键改动文件:
- `src/composables/useAudio.js`:重写为多曲目 useAudio。
- `src/components/PhotoGallery.vue`:新增 7 种版式渲染组件。
- `src/views/index/index.vue`揭幕帘幕、多曲目音乐弹层、PhotoGallery。
- `src/views/admin/AdminEditor.vue`:新增「背景音乐」标签页,相册版式下拉,柔性版式增删图片。
- 配置字段迁移:`musicUrl``musicList + activeMusicUrl`,前后端均做了旧配置兼容。
验证:
- `node node_modules/vite/bin/vite.js build --outDir dist-verify` 通过EXIT=0
- 已清理临时构建输出到 `D:/myCode/hunliji/_dist_verify_bak`(环境安全删除机制损坏,无法真正删除,后续请手动清理)。
- 开发服务器在 8888 正常运行,`/admin` 登录与编辑器可访问;新「背景音乐」标签可见。
---
## 2026-07-31 晚(追加)相册页可设置页高
新增需求:后台每个相册页可独立设置页面高度。
改动:
- `src/views/index/index.vue`:相册页 section 绑定 `pageHeightStyle(page)`,支持 `100vh/80vh/60vh/auto/自定义vh`
- `src/views/admin/AdminEditor.vue`:每个相册页卡片新增「页面高度」选择器(满屏/大屏/半屏/自适应/自定义 vh`ensurePageShape` 默认 `height: '100vh'`,旧配置自动兼容。
验证:
- 构建通过EXIT=0
- 浏览器实测:注入测试 token 进入后台,相册页管理标签下「页面高度」控件正常渲染。

BIN
_shots/admin-music.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
_shots/admin-music2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
_shots/admin-photos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
_shots/guest-intro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
_shots/guest-intro2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
SHOTS=D:/myCode/hunliji/dev/_shots
mkdir -p "$SHOTS"
echo "=== A. login ==="
agent-browser open http://127.0.0.1:8888/admin >/dev/null 2>&1
agent-browser wait --load load >/dev/null 2>&1
agent-browser type "input[placeholder=管理员账号]" admin >/dev/null 2>&1 || true
agent-browser type "input[placeholder=密码]" admin123 >/dev/null 2>&1 || true
agent-browser click "button[type=submit]" >/dev/null 2>&1 || true
agent-browser wait --load load >/dev/null 2>&1
sleep 2
agent-browser snapshot > /tmp/sa.txt 2>&1 || true
grep -c "内容编辑后台" /tmp/sa.txt && echo "OK editor loaded" || echo "WARN editor not loaded"
echo "=== B. 背景音乐 tab ==="
agent-browser click "text=背景音乐" >/dev/null 2>&1 || agent-browser click "背景音乐" >/dev/null 2>&1 || true
sleep 1
agent-browser snapshot > /tmp/sb.txt 2>&1 || true
grep -c "上传音乐" /tmp/sb.txt && echo "OK music upload button" || echo "WARN music upload missing"
grep -c "背景音乐 1" /tmp/sb.txt && echo "OK default track present" || echo "WARN default track missing"
agent-browser screenshot "$SHOTS/admin-music.png" 2>&1 | head -1 || true
echo "=== C. 相册页管理 layout options ==="
agent-browser click "text=相册页管理" >/dev/null 2>&1 || agent-browser click "相册页管理" >/dev/null 2>&1 || true
sleep 1
agent-browser snapshot > /tmp/sc.txt 2>&1 || true
for opt in 单图 错落双图 一大五小 九宫格 瀑布流 轮播滑动 拍立得; do
if grep -q "$opt" /tmp/sc.txt; then echo "OK option: $opt"; else echo "MISS option: $opt"; fi
done
agent-browser screenshot "$SHOTS/admin-photos.png" 2>&1 | head -1 || true
echo "=== D. guest intro ==="
agent-browser open http://127.0.0.1:8888/ >/dev/null 2>&1
agent-browser wait --load load >/dev/null 2>&1
sleep 1
agent-browser snapshot > /tmp/sd.txt 2>&1 || true
grep -c "点击开启请柬" /tmp/sd.txt && echo "OK intro curtain shows" || echo "WARN intro curtain missing"
agent-browser screenshot "$SHOTS/guest-intro.png" 2>&1 | head -1 || true
agent-browser close >/dev/null 2>&1 || true
echo "DONE"

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.admin-tabs[data-v-0b99ae25] .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#a88c6b}.admin-tabs[data-v-0b99ae25] .ant-tabs-ink-bar{background:#a88c6b}.admin-tabs[data-v-0b99ae25] .ant-tabs-tab:hover .ant-tabs-tab-btn{color:#967b5c}

View File

@@ -0,0 +1 @@
import{y as k,d as y,e as t,l as a,m as o,q as n,z,A,b as q,j as c,s as B,r as C,B as p}from"./index-BLDegpD4.js";const N={class:"min-h-screen w-full flex items-center justify-center bg-[#Fdfbf7] relative overflow-hidden"},S={class:"relative z-10 w-[360px] bg-white/80 backdrop-blur-xl border border-[#a88c6b]/15 rounded-3xl shadow-2xl p-9 animate-fade-up"},V={class:"mt-6 text-center"},R={__name:"AdminLogin",setup(j){const m=z(),b=A(),f=k(),i=C(!1),r=B({account:"",password:""}),x=async()=>{var u,e;i.value=!0;try{await f.login(r),p.success("登录成功"),m.replace(b.query.redirect||"/admin")}catch(s){const l=((e=(u=s==null?void 0:s.response)==null?void 0:u.data)==null?void 0:e.error)||(s==null?void 0:s.message)||"登录失败";p.error(l)}finally{i.value=!1}};return(u,e)=>{const s=n("a-input"),l=n("a-form-item"),g=n("a-input-password"),v=n("a-button"),_=n("a-form"),w=n("router-link");return q(),y("div",N,[e[7]||(e[7]=t("div",{class:"absolute -top-24 -right-24 w-80 h-80 rounded-full bg-[#a88c6b]/10 blur-3xl"},null,-1)),e[8]||(e[8]=t("div",{class:"absolute -bottom-24 -left-24 w-80 h-80 rounded-full bg-[#e8a5b2]/10 blur-3xl"},null,-1)),e[9]||(e[9]=t("div",{class:"absolute inset-0 pointer-events-none opacity-[0.04]",style:{"background-image":"radial-gradient(#a88c6b 1px, transparent 1px)","background-size":"22px 22px"}},null,-1)),t("div",S,[e[6]||(e[6]=t("div",{class:"text-center mb-8"},[t("div",{class:"text-3xl mb-3 text-[#a88c6b] font-serif"},"❀"),t("h1",{class:"text-xl tracking-[0.3em] text-[#2c2c2c] font-light"},"婚礼纪 · 后台"),t("p",{class:"text-[11px] text-[#8A8680] tracking-[0.3em] mt-2 uppercase"},"Admin Console")],-1)),a(_,{model:r,layout:"vertical",onFinish:x},{default:o(()=>[a(l,{name:"account",rules:[{required:!0,message:"请输入账号"}]},{default:o(()=>[a(s,{value:r.account,"onUpdate:value":e[0]||(e[0]=d=>r.account=d),size:"large",placeholder:"管理员账号"},{prefix:o(()=>[...e[2]||(e[2]=[t("i",{class:"fa-solid fa-user text-[#a88c6b]"},null,-1)])]),_:1},8,["value"])]),_:1}),a(l,{name:"password",rules:[{required:!0,message:"请输入密码"}]},{default:o(()=>[a(g,{value:r.password,"onUpdate:value":e[1]||(e[1]=d=>r.password=d),size:"large",placeholder:"密码"},{prefix:o(()=>[...e[3]||(e[3]=[t("i",{class:"fa-solid fa-lock text-[#a88c6b]"},null,-1)])]),_:1},8,["value"])]),_:1}),a(v,{type:"primary","html-type":"submit",size:"large",block:"",loading:i.value,class:"!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b] !rounded-xl !tracking-[0.2em]"},{default:o(()=>[...e[4]||(e[4]=[c(" 登 录 ",-1)])]),_:1},8,["loading"])]),_:1},8,["model"]),t("div",V,[a(w,{to:"/",class:"text-[12px] text-[#a88c6b] hover:underline"},{default:o(()=>[...e[5]||(e[5]=[c("返回请柬预览",-1)])]),_:1})])])])}}};export{R as default};

View File

@@ -0,0 +1 @@
const s=(t,r)=>{const o=t.__vccOpts||t;for(const[c,e]of r)o[c]=e;return o};export{s as _};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>婚礼请柬 · Wedding Invitation</title>
<script type="module" crossorigin src="/assets/index-BLDegpD4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CiAU64yc.css">
</head>
<body>
<div id="app"></div>
</body>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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]"
<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 : []
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.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 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" />
<PhotoGallery :page="page" />
</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>
</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>