更新页面效果

This commit is contained in:
李琦
2026-07-31 11:36:19 +08:00
parent 3c4347128d
commit 4630035dd1
39 changed files with 859 additions and 54 deletions

View File

@@ -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-068%/72% + 60%/60% 比例错落。实测修复后两图完整不出界。
3. **单图宽度铺满**`page.singleWidth`(default|full) + `page.singleKeepRatio`(bool)。full+保比例 → 新增 `gf-natural` 辅助类img height:autofull+不保 → 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-browserplaywright-cli 未装)。坑:后台是 history 模式URL 是 `/admin` 不是 `/#/admin`eval 多行 JS 引号易炸,写临时文件 `eval "$(cat x.js)"` 最稳;截图保存在 daemon 的 cwdD:/myCode/hunliji/dev/_shots
验证:`vite build` 通过EXIT=0浏览器实测 overlap/single-full/admin 控件均正常。注:沙箱浏览器加载不了 unsplash显示裂图localhost:8080 上传图正常。

View File

@@ -0,0 +1,20 @@
# 婚礼请柬项目 · 长期备忘
## 架构
- 前端 `vite-tailwindcss`Vue3+Vite6+Tailwind4+Pinia+antd路由 history 模式(`/admin` 非 hashdev 端口 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-browserplaywright-cli 未装):多行 eval 写临时文件再 `eval "$(cat f)"`;截图落在 daemon cwdadmin 需先 localStorage 注入 admin_token可用 network route mock /api/config 验证配置渲染(不动数据库)。
## 凭证
后台 admin / admin123env 可覆盖)。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
_shots/admin-visual.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
_shots/overlap-after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
_shots/overlap-before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
_shots/overlap-final.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
_shots/single-full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long

View File

@@ -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}

View File

@@ -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};

View File

@@ -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};

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-CZ-hSlJt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bh11Omyp.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

@@ -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; }

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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') || [])
// 带时长的平滑滚动easeInOutQuadduration 由后台 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)
})