初始化
2
.idea/vcs.xml
generated
@@ -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>
|
||||
32
.workbuddy/memory/2026-07-31.md
Normal 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
|
After Width: | Height: | Size: 64 KiB |
BIN
_shots/admin-music2.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
_shots/admin-photos-height.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
_shots/admin-photos.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
_shots/guest-intro.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
_shots/guest-intro2.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
42
vite-tailwindcss/_smoke.sh
Normal 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"
|
||||
@@ -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}
|
||||
@@ -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};
|
||||
@@ -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 _};
|
||||
BIN
vite-tailwindcss/dist-verify/assets/fa-brands-400-D1LuMI3I.ttf
Normal file
BIN
vite-tailwindcss/dist-verify/assets/fa-brands-400-D_cYUPeE.woff2
Normal file
BIN
vite-tailwindcss/dist-verify/assets/fa-regular-400-DZaxPHgR.ttf
Normal file
BIN
vite-tailwindcss/dist-verify/assets/fa-solid-900-CTAAxXor.woff2
Normal file
BIN
vite-tailwindcss/dist-verify/assets/fa-solid-900-D0aA9rwL.ttf
Normal file
514
vite-tailwindcss/dist-verify/assets/index-BLDegpD4.js
Normal file
5
vite-tailwindcss/dist-verify/assets/index-BzNcHYy9.js
Normal file
5
vite-tailwindcss/dist-verify/assets/index-CiAU64yc.css
Normal file
1
vite-tailwindcss/dist-verify/assets/index-dWpH1MeA.css
Normal file
14
vite-tailwindcss/dist-verify/index.html
Normal 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>
|
||||
1
vite-tailwindcss/dist-verify/vite.svg
Normal 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 |
153
vite-tailwindcss/src/components/PhotoGallery.vue
Normal 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>
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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">&</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">&</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>
|
||||
|
||||