更新页面效果
@@ -95,3 +95,29 @@
|
||||
- 授权模态框模板/样式保持不变(轻触开启 → 播放;暂不开启 → 挂 `armAutoplay` 兜底)。
|
||||
|
||||
验证:`vite build --outDir dist-chk-<timestamp>` 通过(EXIT=0)。无残留 `pendingMusicAuth` 引用。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-31(第七版)滚动修复+速度参数+错落双图+单图宽度+边框扩展+背景装饰
|
||||
|
||||
用户提出 5 大块需求(附截图),全部实现并浏览器实测验证:
|
||||
|
||||
1. **首屏滚动修复+后台速度参数**
|
||||
- 根因:容器带 `scroll-smooth` 类,CSS smooth 会劫持 JS 的 `scrollTop` 赋值,free 模式每帧 +=0.8 全被转成平滑动画 → 卡顿跳帧。已移除该类并注释警示。
|
||||
- snap 切换动画时长不可控(behavior:'smooth' 浏览器自定)→ 自研 `smoothScrollTo`(rAF + easeInOutQuad,时长可配)。
|
||||
- 新配置:`freeScrollInterval`(ms,默认50,自由滚动间隔,setInterval 驱动)、`pageSwitchDuration`(ms,默认800,翻页动画时长)。后台「视觉与动效」加滑块+数字输入。
|
||||
- 循环逻辑:snap/free 到底后回第一屏继续;`handleUserInteraction` 取消程序化动画避免争抢。
|
||||
|
||||
2. **错落双图溢出修复**(先实测定位再修)
|
||||
- 实测根因:保存的配置中该页 `height:'auto'` → section 仅 643px(视口 844),容器又写死 `h-[50vh]`,与页高脱钩 + `bottom-4` 悬空 → 第二图被裁/挤压。
|
||||
- 修复:容器改 `clamp(320px,54vh,460px)` 封顶,图片贴容器边缘(bottom-0/right-0),68%/72% + 60%/60% 比例错落。实测修复后两图完整不出界。
|
||||
|
||||
3. **单图宽度铺满**:`page.singleWidth`(default|full) + `page.singleKeepRatio`(bool)。full+保比例 → 新增 `gf-natural` 辅助类(img height:auto);full+不保 → aspect 4/5 裁切。后台单图版式显示该配置(单选+开关),实测铺满渲染正常。
|
||||
|
||||
4. **边框扩展5组**:gf-shadow(阴影浮起)/gf-ink(墨黑描边)/gf-card(悬浮卡片)/gf-lace(虚线文艺)/gf-glow(柔光金晕),共 11 种,后台预览复用。
|
||||
|
||||
5. **背景装饰优化**:petalDrift/heartSway 从 margin-left(每帧触发布局)改嵌套内层 transform(全合成层,性能修复);爱心三层景深(远小淡模糊/近大清晰)、花瓣两层景深;新增 bg-glow 柔光层(淡金/淡粉 radial 光斑缓慢漂移)。
|
||||
|
||||
实测工具:agent-browser(playwright-cli 未装)。坑:后台是 history 模式,URL 是 `/admin` 不是 `/#/admin`;eval 多行 JS 引号易炸,写临时文件 `eval "$(cat x.js)"` 最稳;截图保存在 daemon 的 cwd(D:/myCode/hunliji/dev/_shots)。
|
||||
|
||||
验证:`vite build` 通过(EXIT=0);浏览器实测 overlap/single-full/admin 控件均正常。注:沙箱浏览器加载不了 unsplash(显示裂图),localhost:8080 上传图正常。
|
||||
|
||||
20
.workbuddy/memory/MEMORY.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 婚礼请柬项目 · 长期备忘
|
||||
|
||||
## 架构
|
||||
- 前端 `vite-tailwindcss`:Vue3+Vite6+Tailwind4+Pinia+antd,路由 history 模式(`/admin` 非 hash),dev 端口 8888,代理 `/api` → 8080。
|
||||
- 后端 `hunliji-api`:Go/Gin+GORM+MySQL,改完必须 `go build` 并重启旧进程才生效(404 多半是旧二进制)。
|
||||
- 配置存后端单条 JSON,前后端均做旧字段兼容(musicUrl→musicList、缺省 height/borderStyle/singleWidth/速度参数)。
|
||||
|
||||
## 关键坑(不要再踩)
|
||||
1. **滚动容器绝不能加 `scroll-smooth`**:CSS smooth 会劫持 JS 的 scrollTop 赋值,free 自动滚动会严重卡顿。snap 翻页用自研 rAF 动画(`smoothScrollTo`,时长=pageSwitchDuration)。
|
||||
2. **动画摇曳禁用 margin-left**(每帧触发布局):外层跑位移、内层元素跑 transform 摇曳(petal/petal-inner、heart-bubble/heart-inner 模式)。
|
||||
3. **相册页高与图片容器要联动**:页高可能 auto/80vh,图片容器写死 vh 会脱钩溢出 → 用 clamp 封顶 + 图片贴容器边缘。
|
||||
4. `gf-frame` 不设 width/height(尺寸交给版式包装类),`gf-natural` 用于保持原始比例。
|
||||
|
||||
## 环境
|
||||
- 本机"安全删除"损坏:rm 常失败,vite build 用 `--outDir dist-chk-<ts>` 新目录验证,临时目录需手动清。
|
||||
- 沙箱网络隔离:测宿主机服务要 dangerouslyDisableSandbox;沙箱浏览器加载不了 unsplash(裂图正常),localhost 图正常。
|
||||
- 浏览器实测用 agent-browser(playwright-cli 未装):多行 eval 写临时文件再 `eval "$(cat f)"`;截图落在 daemon cwd;admin 需先 localStorage 注入 admin_token;可用 network route mock /api/config 验证配置渲染(不动数据库)。
|
||||
|
||||
## 凭证
|
||||
后台 admin / admin123(env 可覆盖)。
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 92 KiB |
BIN
_shots/admin-single-full.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
_shots/admin-visual.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
_shots/overlap-after.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
_shots/overlap-before.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
_shots/overlap-final.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
_shots/single-full.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
hunliji-api/uploads/1785468831_1774761374341_compressed.webp
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
hunliji-api/uploads/1785468833_1774761377281_compressed.webp
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
hunliji-api/uploads/1785468834_1774761380354_compressed.webp
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
hunliji-api/uploads/1785468836_1774763086396_compressed.webp
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
hunliji-api/uploads/1785468875_1774761309141_compressed.webp
Normal file
|
After Width: | Height: | Size: 750 KiB |
BIN
hunliji-api/uploads/1785468878_1774761368402_compressed.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1 @@
|
||||
.admin-tabs[data-v-2abef6e9] .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#a88c6b}.admin-tabs[data-v-2abef6e9] .ant-tabs-ink-bar{background:#a88c6b}.admin-tabs[data-v-2abef6e9] .ant-tabs-tab:hover .ant-tabs-tab-btn{color:#967b5c}
|
||||
@@ -0,0 +1 @@
|
||||
import{x as k,d as y,e as t,q as a,y as o,z as n,A as z,B as A,b as q,k as c,p as B,r as C,C as p}from"./index-CZ-hSlJt.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"},U={__name:"AdminLogin",setup(F){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{U as default};
|
||||
@@ -0,0 +1 @@
|
||||
const v=[{value:"none",label:"无边框"},{value:"thin",label:"细金线"},{value:"mat",label:"白卡纸"},{value:"rounded",label:"柔圆角"},{value:"double",label:"双线框"},{value:"gold",label:"古典金框"},{value:"shadow",label:"阴影浮起"},{value:"ink",label:"墨黑描边"},{value:"card",label:"悬浮卡片"},{value:"lace",label:"虚线文艺"},{value:"glow",label:"柔光金晕"}];function t(l){return"gf-frame gf-"+(v.some(a=>a.value===l)?l:"mat")}const b=(l,e)=>{const a=l.__vccOpts||l;for(const[o,u]of e)a[o]=u;return a};export{v as B,b as _,t as b};
|
||||
514
vite-tailwindcss/dist-chk-1785466613/assets/index-CZ-hSlJt.js
Normal file
14
vite-tailwindcss/dist-chk-1785466613/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-CZ-hSlJt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bh11Omyp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
1
vite-tailwindcss/dist-chk-1785466613/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 |
@@ -7,18 +7,18 @@
|
||||
<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] mt-6">
|
||||
<div :class="frameClass"><img :src="page.img1" /></div>
|
||||
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
|
||||
<div :class="singleFrameClass"><img :src="page.img1" /></div>
|
||||
</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%] z-10" :class="frameClass">
|
||||
<!-- 错落双图(高度 clamp 封顶,图片贴容器边缘,任何页高下不溢出) -->
|
||||
<div v-else-if="page.type === 'overlap'" class="overlap-wrap relative w-full mt-2">
|
||||
<div v-reveal="{ variant: 'left', delay: 100 }" class="absolute top-0 right-0 w-[68%] h-[72%] z-10" :class="frameClass">
|
||||
<img :src="page.img1" />
|
||||
</div>
|
||||
<div v-reveal="{ variant: 'right', delay: 300 }" class="absolute bottom-4 left-4 w-[60%] h-[60%] z-20" :class="frameClass">
|
||||
<div v-reveal="{ variant: 'right', delay: 300 }" class="absolute bottom-0 left-0 w-[60%] h-[60%] z-20" :class="frameClass">
|
||||
<img :src="page.img2" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +89,23 @@ const props = defineProps({
|
||||
// 边框样式:前台按 page.borderStyle 套用全局 gf-* 类
|
||||
const frameClass = computed(() => borderClass(props.page.borderStyle))
|
||||
|
||||
/* ---------- 单图宽度模式 ---------- */
|
||||
// singleWidth: 'default'(居中卡幅) | 'full'(宽度铺满);singleKeepRatio: 铺满时是否保持原始宽高比
|
||||
const singleWrapClass = computed(() =>
|
||||
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
|
||||
)
|
||||
const singleWrapStyle = computed(() => {
|
||||
if (props.page.singleWidth === 'full') {
|
||||
// 铺满 + 保持原始比例 → 高度由图片自然撑开;铺满 + 不保持 → 固定 4:5 裁切
|
||||
return props.page.singleKeepRatio ? {} : { aspectRatio: '4 / 5' }
|
||||
}
|
||||
return { aspectRatio: '4 / 5' }
|
||||
})
|
||||
// 保持原始比例时 img 需要自然高度(gf-natural 覆盖 gf-frame 的 height:100%)
|
||||
const singleFrameClass = computed(() =>
|
||||
frameClass.value + (props.page.singleWidth === 'full' && props.page.singleKeepRatio ? ' gf-natural' : '')
|
||||
)
|
||||
|
||||
/* ---------- 轮播状态 ---------- */
|
||||
const index = ref(0)
|
||||
const trackRef = ref(null)
|
||||
@@ -120,6 +137,9 @@ function polaroidTransform(i) {
|
||||
<style scoped>
|
||||
.gallery { width: 100%; }
|
||||
|
||||
/* 错落双图:高度随视口缩放并封顶,保证标题+图片总高始终落在页高预算内 */
|
||||
.overlap-wrap { height: clamp(320px, 54vh, 460px); }
|
||||
|
||||
/* 瀑布流 */
|
||||
.g-masonry { column-count: 2; column-gap: 10px; }
|
||||
.g-masonry-item { break-inside: avoid; margin-bottom: 10px; border-radius: 6px; }
|
||||
|
||||
@@ -6,6 +6,11 @@ export const BORDER_STYLES = [
|
||||
{ value: 'rounded', label: '柔圆角' },
|
||||
{ value: 'double', label: '双线框' },
|
||||
{ value: 'gold', label: '古典金框' },
|
||||
{ value: 'shadow', label: '阴影浮起' },
|
||||
{ value: 'ink', label: '墨黑描边' },
|
||||
{ value: 'card', label: '悬浮卡片' },
|
||||
{ value: 'lace', label: '虚线文艺' },
|
||||
{ value: 'glow', label: '柔光金晕' },
|
||||
]
|
||||
|
||||
export function borderClass(style) {
|
||||
|
||||
@@ -26,44 +26,76 @@
|
||||
}
|
||||
|
||||
/* ---------- 飘落花瓣(优雅:柔和渐变 + 轻微摇曳) ---------- */
|
||||
/* 性能约定:摇曳动画一律作用在内层元素的 transform 上,
|
||||
外层只跑位移动画 —— 全程合成层处理,不触发布局。 */
|
||||
@keyframes petalFall {
|
||||
0% { transform: translate3d(0, -12vh, 0) rotate(0deg); opacity: 0; }
|
||||
10% { opacity: 0.85; }
|
||||
90% { opacity: 0.7; }
|
||||
10% { opacity: 0.8; }
|
||||
90% { opacity: 0.65; }
|
||||
100% { transform: translate3d(6vw, 112vh, 0) rotate(360deg); opacity: 0; }
|
||||
}
|
||||
@keyframes petalDrift {
|
||||
0%, 100% { margin-left: -14px; }
|
||||
50% { margin-left: 14px; }
|
||||
0%, 100% { transform: translateX(-12px) rotate(-7deg); }
|
||||
50% { transform: translateX(12px) rotate(7deg); }
|
||||
}
|
||||
.petal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
animation:
|
||||
petalFall var(--p-dur, 12s) linear infinite,
|
||||
petalDrift var(--p-sway, 6s) ease-in-out infinite alternate;
|
||||
animation: petalFall var(--p-dur, 12s) linear infinite;
|
||||
}
|
||||
.petal-inner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
animation: petalDrift var(--p-sway, 6s) ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* ---------- 漂浮爱心(全屏自然上升,随机分布) ---------- */
|
||||
/* ---------- 漂浮爱心(全屏自然上升,三层景深) ---------- */
|
||||
@keyframes heartFloat {
|
||||
0% { transform: translateY(20px) translateX(0) scale(0.7); opacity: 0; }
|
||||
12% { opacity: 0.8; }
|
||||
85% { opacity: 0.6; }
|
||||
100% { transform: translateY(-118vh) translateX(24px) scale(1.1); opacity: 0; }
|
||||
0% { transform: translate3d(0, 20px, 0) scale(0.7); opacity: 0; }
|
||||
12% { opacity: var(--h-opacity, 0.7); }
|
||||
85% { opacity: calc(var(--h-opacity, 0.7) * 0.75); }
|
||||
100% { transform: translate3d(24px, -118vh, 0) scale(1.08); opacity: 0; }
|
||||
}
|
||||
@keyframes heartSway {
|
||||
0%, 100% { margin-left: -16px; }
|
||||
50% { margin-left: 16px; }
|
||||
0%, 100% { transform: translateX(-14px) scale(0.97); }
|
||||
50% { transform: translateX(14px) scale(1.03); }
|
||||
}
|
||||
.heart-bubble {
|
||||
position: absolute;
|
||||
will-change: transform, opacity;
|
||||
animation:
|
||||
heartFloat var(--float-duration, 18s) linear infinite,
|
||||
heartSway var(--sway-duration, 5s) ease-in-out infinite alternate;
|
||||
animation: heartFloat var(--float-duration, 18s) linear infinite;
|
||||
}
|
||||
.heart-inner {
|
||||
display: inline-block;
|
||||
will-change: transform;
|
||||
animation: heartSway var(--sway-duration, 5s) ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* ---------- 背景柔光层(淡金/淡粉光斑,缓慢漂移,不喧宾夺主) ---------- */
|
||||
.bg-glow { position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
|
||||
.bg-glow i { position: absolute; border-radius: 50%; filter: blur(46px); will-change: transform; }
|
||||
.bg-glow .g1 {
|
||||
width: 300px; height: 300px; top: -90px; right: -70px;
|
||||
background: radial-gradient(circle, rgba(233, 206, 168, 0.5), transparent 68%);
|
||||
animation: glowDrift1 17s ease-in-out infinite alternate;
|
||||
}
|
||||
.bg-glow .g2 {
|
||||
width: 260px; height: 260px; bottom: -80px; left: -60px;
|
||||
background: radial-gradient(circle, rgba(244, 194, 194, 0.42), transparent 68%);
|
||||
animation: glowDrift2 21s ease-in-out infinite alternate;
|
||||
}
|
||||
.bg-glow .g3 {
|
||||
width: 210px; height: 210px; top: 38%; left: 58%;
|
||||
background: radial-gradient(circle, rgba(212, 175, 55, 0.16), transparent 70%);
|
||||
animation: glowDrift3 24s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes glowDrift1 { to { transform: translate(-34px, 26px) scale(1.08); } }
|
||||
@keyframes glowDrift2 { to { transform: translate(30px, -24px) scale(1.06); } }
|
||||
@keyframes glowDrift3 { to { transform: translate(-26px, 20px) scale(1.12); } }
|
||||
|
||||
/* ---------- 心跳脉冲 ---------- */
|
||||
@keyframes pulseHeart {
|
||||
|
||||
@@ -69,3 +69,57 @@
|
||||
box-shadow: 0 0 0 4px #fff, 0 0 0 5px #e7d6bb, 0 8px 22px rgba(120, 90, 60, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 阴影浮起(无边框,大柔影悬浮感) */
|
||||
.gf-shadow {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 44px rgba(96, 72, 50, 0.2), 0 4px 12px rgba(96, 72, 50, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 墨黑描边(简约深色细框) */
|
||||
.gf-ink {
|
||||
padding: 6px;
|
||||
border: 1.5px solid #3a3733;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 悬浮卡片(厚白边 + 大圆角 + 层叠影) */
|
||||
.gf-card {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(168, 140, 107, 0.18);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 6px rgba(120, 90, 60, 0.08), 0 16px 36px rgba(120, 90, 60, 0.16);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 虚线文艺(dashed 描边 + 内白边) */
|
||||
.gf-lace {
|
||||
padding: 7px;
|
||||
border: 1.5px dashed #b89a78;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 12px rgba(120, 90, 60, 0.08);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 柔光金晕(浅金边 + 外发光) */
|
||||
.gf-glow {
|
||||
padding: 5px;
|
||||
border: 1px solid #e7d3ae;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 3px rgba(231, 211, 174, 0.35), 0 0 26px rgba(201, 169, 120, 0.45), 0 6px 16px rgba(120, 90, 60, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 辅助:图片保持原始宽高比(单图铺满+保比例时使用,覆盖 gf-frame 的 height:100%) */
|
||||
.gf-natural {
|
||||
height: auto !important;
|
||||
}
|
||||
.gf-natural > img {
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,22 @@
|
||||
</a-radio-group>
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">整页模式会带来更有仪式感的逐屏切换效果。</div>
|
||||
</a-form-item>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<a-form-item label="自由滚动速度(滚动间隔)">
|
||||
<div class="flex gap-3 items-center">
|
||||
<a-slider v-model:value="cfg.freeScrollInterval" :min="10" :max="500" class="flex-1" />
|
||||
<a-input-number v-model:value="cfg.freeScrollInterval" :min="10" :max="500" addon-after="ms" class="w-[120px]" />
|
||||
</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">间隔越小滚动越快,默认 50ms。仅在「自由滚动」模式下生效。</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="页面切换速度(动画时长)">
|
||||
<div class="flex gap-3 items-center">
|
||||
<a-slider v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" class="flex-1" />
|
||||
<a-input-number v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" addon-after="ms" class="w-[120px]" />
|
||||
</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">整页/分屏模式下自动翻页的动画时长,默认 800ms。</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a-form-item label="飘落花瓣">
|
||||
<a-switch v-model:checked="cfg.petalEnabled" />
|
||||
@@ -185,6 +201,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 单图版式的宽度设置(逐页配置) -->
|
||||
<div v-if="page.type==='single'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start">
|
||||
<a-form-item label="图片宽度">
|
||||
<a-radio-group v-model:value="page.singleWidth">
|
||||
<a-radio value="default">默认(居中卡幅)</a-radio>
|
||||
<a-radio value="full">宽度铺满</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="page.singleWidth==='full'" label="保持原始宽高比">
|
||||
<a-switch v-model:checked="page.singleKeepRatio" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">开启:按图片原始比例显示;关闭:固定 4:5 比例裁切铺满。</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" />
|
||||
@@ -295,6 +324,7 @@ function defaultConfig() {
|
||||
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,
|
||||
freeScrollInterval: 50, pageSwitchDuration: 800,
|
||||
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', height: '100vh', borderStyle: 'mat' },
|
||||
{ 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', borderStyle: 'mat' },
|
||||
@@ -354,6 +384,9 @@ function ensurePageShape(p) {
|
||||
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
|
||||
p.height = p.height || '100vh'
|
||||
p.borderStyle = p.borderStyle || 'mat'
|
||||
// 单图宽度设置(仅 single 版式使用,逐页配置)
|
||||
p.singleWidth = p.singleWidth === 'full' ? 'full' : 'default'
|
||||
if (typeof p.singleKeepRatio !== 'boolean') p.singleKeepRatio = true
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -482,6 +515,9 @@ onMounted(async () => {
|
||||
}
|
||||
Object.assign(cfg, loaded)
|
||||
cfg.photoPages = (cfg.photoPages || []).map(ensurePageShape)
|
||||
// 数值型参数兜底(兼容旧配置)
|
||||
cfg.freeScrollInterval = Number(cfg.freeScrollInterval) || 50
|
||||
cfg.pageSwitchDuration = Number(cfg.pageSwitchDuration) || 800
|
||||
}
|
||||
} catch (e) { /* 使用默认配置 */ }
|
||||
if (adminStore.isAdmin) loadRsvp()
|
||||
|
||||
@@ -19,22 +19,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 飘落花瓣层 -->
|
||||
<!-- 背景柔光层(淡金/淡粉光斑,缓慢漂移) -->
|
||||
<div class="bg-glow" aria-hidden="true"><i class="g1"></i><i class="g2"></i><i class="g3"></i></div>
|
||||
|
||||
<!-- 飘落花瓣层(外层位移 + 内层摇曳,全 transform 不触发布局) -->
|
||||
<div v-if="data.petalEnabled" class="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
|
||||
<div v-for="p in petals" :key="'p'+p.id" class="petal"
|
||||
:style="{ left: p.left, width: p.size+'px', height: p.size+'px', opacity: p.opacity, animationDelay: p.delay, '--p-dur': p.duration, '--p-sway': p.sway }">
|
||||
<svg viewBox="0 0 100 100" class="w-full h-full" :style="{ filter: 'drop-shadow(0 2px 3px rgba(150,110,90,0.12))' }">
|
||||
:style="{ left: p.left, width: p.size+'px', height: p.size+'px', opacity: p.opacity, animationDelay: p.delay, '--p-dur': p.duration }">
|
||||
<svg viewBox="0 0 100 100" class="petal-inner" :style="{ '--p-sway': p.sway, filter: p.filter }">
|
||||
<path d="M50 3 C28 26 20 62 50 97 C80 62 72 26 50 3 Z" :fill="p.color" opacity="0.9" />
|
||||
<path d="M50 9 C50 36 50 70 50 91" stroke="rgba(255,255,255,0.55)" stroke-width="1.4" fill="none" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 漂浮爱心层 -->
|
||||
<!-- 漂浮爱心层(三层景深:远小淡模糊 / 近大清晰) -->
|
||||
<div v-if="data.bubbleEnabled" class="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
|
||||
<div v-for="b in bubbles" :key="'b'+b.id"
|
||||
class="heart-bubble flex items-center justify-center"
|
||||
:style="{ left: b.left, top: b.top, color: b.color, fontSize: b.size+'px', opacity: b.opacity, animationDelay: b.delay, '--float-duration': b.floatDuration, '--sway-duration': b.swayDuration }">♥</div>
|
||||
:style="{ left: b.left, top: b.top, color: b.color, fontSize: b.size+'px', animationDelay: b.delay, '--float-duration': b.floatDuration, '--h-opacity': b.opacity }">
|
||||
<span class="heart-inner" :style="{ '--sway-duration': b.swayDuration, filter: b.filter }">♥</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部滚动进度条 -->
|
||||
@@ -72,9 +77,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主滚动区域 -->
|
||||
<!-- 主滚动区域(注意:不要加 scroll-smooth,它会劫持 JS 滚动赋值导致卡顿) -->
|
||||
<div ref="scrollContainerRef"
|
||||
class="w-full h-full overflow-y-auto no-scrollbar relative scroll-smooth"
|
||||
class="w-full h-full overflow-y-auto no-scrollbar relative"
|
||||
:class="data.scrollMode === 'snap' ? 'snap-y snap-mandatory' : ''"
|
||||
@scroll="handleScroll" @touchstart="handleUserInteraction" @touchmove="handleUserInteraction"
|
||||
@wheel="handleUserInteraction" @mousedown="handleUserInteraction">
|
||||
@@ -223,6 +228,8 @@ import PhotoGallery from '@/components/PhotoGallery.vue'
|
||||
const DEFAULTS = {
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
musicList: [], activeMusicUrl: '',
|
||||
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
|
||||
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
|
||||
}
|
||||
|
||||
const data = ref({
|
||||
@@ -247,22 +254,59 @@ const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(fal
|
||||
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: '遗憾缺席' } ]
|
||||
|
||||
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null
|
||||
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null
|
||||
|
||||
const loveColors = ['#a88c6b', '#e8a5b2', '#f4c2c2', '#d4af37']
|
||||
const petalColors = ['#f7d9e0', '#fbeee3', '#f3c6d3', '#efd9c4', '#f9e7d6']
|
||||
const bubbles = Array.from({ length: 15 }).map((_, i) => ({
|
||||
id: i, left: Math.random() * 100 + '%', top: Math.random() * 100 + '%',
|
||||
color: loveColors[i % 4], size: 13 + Math.random() * 10, opacity: 0.25 + Math.random() * 0.15,
|
||||
floatDuration: (14 + Math.random() * 10) + 's', swayDuration: (4 + Math.random() * 2) + 's',
|
||||
delay: '-' + (Math.random() * 18).toFixed(1) + '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',
|
||||
sway: (4 + Math.random() * 4) + 's',
|
||||
}))
|
||||
// 后台可配置的滚动参数(带默认值兜底,防止脏数据)
|
||||
const freeInterval = computed(() => {
|
||||
const v = Number(data.value.freeScrollInterval)
|
||||
return v >= 10 && v <= 1000 ? v : 50
|
||||
})
|
||||
const switchDuration = computed(() => {
|
||||
const v = Number(data.value.pageSwitchDuration)
|
||||
return v >= 0 && v <= 5000 ? v : 800
|
||||
})
|
||||
|
||||
// 装饰元素配色(柔化后的低饱和金粉系,与主题协调不抢眼)
|
||||
const loveColors = ['#c9b08a', '#e2b3c0', '#d4af37', '#e8c9d2']
|
||||
const petalColors = ['#f7dce3', '#fbeee3', '#f3c9d6', '#efdcc8', '#f9e7d6']
|
||||
|
||||
// 爱心三层景深:远(小/淡/模糊) 中 近(大/清晰),营造纵深而不杂乱
|
||||
const heartTiers = [
|
||||
{ n: 5, size: [10, 13], opacity: [0.1, 0.16], blur: 1.6, dur: [20, 26] }, // 远景
|
||||
{ n: 4, size: [14, 17], opacity: [0.16, 0.24], blur: 0.7, dur: [16, 21] }, // 中景
|
||||
{ n: 3, size: [18, 22], opacity: [0.24, 0.32], blur: 0, dur: [13, 17] }, // 近景
|
||||
]
|
||||
let heartId = 0
|
||||
const bubbles = heartTiers.flatMap((t) =>
|
||||
Array.from({ length: t.n }).map(() => {
|
||||
const id = heartId++
|
||||
return {
|
||||
id,
|
||||
left: Math.random() * 100 + '%', top: Math.random() * 100 + '%',
|
||||
color: loveColors[id % loveColors.length],
|
||||
size: t.size[0] + Math.random() * (t.size[1] - t.size[0]),
|
||||
opacity: +(t.opacity[0] + Math.random() * (t.opacity[1] - t.opacity[0])).toFixed(2),
|
||||
filter: t.blur ? `blur(${t.blur}px)` : 'none',
|
||||
floatDuration: (t.dur[0] + Math.random() * (t.dur[1] - t.dur[0])).toFixed(1) + 's',
|
||||
swayDuration: (4.5 + Math.random() * 2.5).toFixed(1) + 's',
|
||||
delay: '-' + (Math.random() * 20).toFixed(1) + 's',
|
||||
}
|
||||
})
|
||||
)
|
||||
// 花瓣:两层景深,远处更小更淡带轻微模糊
|
||||
const petals = Array.from({ length: 16 }).map((_, i) => {
|
||||
const far = i % 3 === 0
|
||||
return {
|
||||
id: i, left: Math.random() * 100 + '%',
|
||||
size: far ? 8 + Math.random() * 7 : 13 + Math.random() * 11,
|
||||
opacity: far ? 0.3 + Math.random() * 0.15 : 0.42 + Math.random() * 0.3,
|
||||
color: petalColors[i % petalColors.length],
|
||||
filter: far ? 'blur(1.2px) drop-shadow(0 2px 3px rgba(150,110,90,0.08))' : 'drop-shadow(0 2px 3px rgba(150,110,90,0.12))',
|
||||
duration: (far ? 13 : 9) + Math.random() * 8 + 's',
|
||||
delay: (Math.random() * 10) + 's',
|
||||
sway: (4 + Math.random() * 4) + 's',
|
||||
}
|
||||
})
|
||||
|
||||
const curtainBg = computed(() => ({ backgroundImage: `url(${data.value.heroImg})` }))
|
||||
|
||||
@@ -285,18 +329,37 @@ const handleScroll = () => {
|
||||
|
||||
const handleUserInteraction = () => {
|
||||
isInteracting.value = true
|
||||
// 用户接管时立即取消程序化的翻页动画,避免与人争抢滚动
|
||||
cancelAnimationFrame(animId)
|
||||
clearTimeout(pauseTimer)
|
||||
pauseTimer = setTimeout(() => (isInteracting.value = false), 3000)
|
||||
}
|
||||
|
||||
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
|
||||
|
||||
// 带时长的平滑滚动(easeInOutQuad),duration 由后台 pageSwitchDuration 控制
|
||||
const smoothScrollTo = (el, target, duration) => {
|
||||
cancelAnimationFrame(animId)
|
||||
const start = el.scrollTop, dist = target - start
|
||||
if (!dist) return
|
||||
if (duration <= 0) { el.scrollTop = target; return }
|
||||
const t0 = performance.now()
|
||||
const tick = (now) => {
|
||||
const p = Math.min(1, (now - t0) / duration)
|
||||
const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2
|
||||
el.scrollTop = start + dist * e
|
||||
if (p < 1) animId = requestAnimationFrame(tick)
|
||||
}
|
||||
animId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const goNextSection = () => {
|
||||
const el = scrollContainerRef.value
|
||||
if (!el) return
|
||||
const top = el.scrollTop
|
||||
const next = sectionEls().find((e) => e.offsetTop > top + 12)
|
||||
if (next) el.scrollTo({ top: next.offsetTop, behavior: 'smooth' })
|
||||
else isAutoScroll.value = false
|
||||
// 循环逻辑:滚到最后一屏后回到第一屏继续轮播
|
||||
smoothScrollTo(el, next ? next.offsetTop : 0, switchDuration.value)
|
||||
}
|
||||
|
||||
const startAutoScroll = () => {
|
||||
@@ -305,15 +368,14 @@ const startAutoScroll = () => {
|
||||
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection()
|
||||
}, 4500)
|
||||
} else {
|
||||
const step = () => {
|
||||
// 自由滚动:setInterval 按后台配置的间隔匀速推进(间隔越小越快)
|
||||
freeTimer = setInterval(() => {
|
||||
const el = scrollContainerRef.value
|
||||
if (el && isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) {
|
||||
el.scrollTop += 0.8
|
||||
if (el.scrollTop >= el.scrollHeight - el.clientHeight - 5) isAutoScroll.value = false
|
||||
el.scrollTop += 1.2
|
||||
if (el.scrollTop >= el.scrollHeight - el.clientHeight - 5) el.scrollTop = 0
|
||||
}
|
||||
rafId = requestAnimationFrame(step)
|
||||
}
|
||||
step()
|
||||
}, freeInterval.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +440,9 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafId)
|
||||
cancelAnimationFrame(animId)
|
||||
clearInterval(snapTimer)
|
||||
clearInterval(freeTimer)
|
||||
clearTimeout(pauseTimer)
|
||||
clearTimeout(introTimer)
|
||||
})
|
||||
|
||||