1. 礼物特效

This commit is contained in:
李琦
2026-08-03 13:19:09 +08:00
parent abcb53b88e
commit 8a4b3f4ab7
8 changed files with 532 additions and 173 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/1774761336736_compressed_compressed.webp" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="💍公历2026年9月4日岁在丙午仲秋之吉四日佳期。谨订良缘敢邀台驾。" />
<meta property="og:type" content="website" />
<meta property="og:title" content="💕李琦❤️李逸烁 💍💒婚礼请柬" />

View File

@@ -1155,6 +1155,96 @@ function animate(timestamp) {
}
}
// 背景装饰先画:爱心 / 轨道光点(不压字)
if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) {
const outlineScale = heartOutlineScale(mt, now)
const outlineOpacity = heartOutlineOpacity(mt, now) * (ENABLE_NEON_GLOW ? 0.75 : 0.65)
const outlineSize = fontSize * 1.75 * outlineScale
ctx.save()
ctx.translate(cx, cy)
if (ENABLE_NEON_GLOW) {
const neonLayers = [
{ widthRatio: 0.038, alpha: 0.16, blur: 14 * SHADOW_MUL },
{ widthRatio: 0.016, alpha: 0.4, blur: 7 * SHADOW_MUL },
{ widthRatio: 0.005, alpha: 0.7, blur: 2 * SHADOW_MUL },
]
neonLayers.forEach((layer, idx) => {
ctx.globalAlpha = outlineOpacity * layer.alpha
ctx.strokeStyle = idx === 2 ? '#ffffff' : mt.glowColor
ctx.lineWidth = Math.max(1, outlineSize * layer.widthRatio)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = layer.blur
drawHeartPath(ctx, 0, 0, outlineSize)
ctx.stroke()
})
} else {
ctx.globalAlpha = outlineOpacity
ctx.strokeStyle = mt.glowColor
ctx.lineWidth = Math.max(1, outlineSize * 0.022)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = (isIOS ? 0 : outlineSize * 0.16) * SHADOW_MUL
drawHeartPath(ctx, 0, 0, outlineSize)
ctx.stroke()
}
ctx.restore()
}
if (mt.showInterlockHearts && opacity > 0.12) {
const p = textProgress(mt, now)
const grow = easeOutCubic(Math.min(1, p / 0.28))
const fade = p > 0.78 ? Math.max(0, 1 - (p - 0.78) / 0.22) : 1
const breath = Math.sin((now - mt.birthTime) / 900) * 0.04
const hs = fontSize * (1.35 + 0.22 * grow) * scale
const ox = fontSize * 0.58 * scale
ctx.save()
ctx.translate(cx, cy)
const drawInterlockHeart = (offsetX, rotateAngle) => {
ctx.save()
ctx.translate(offsetX, 0)
ctx.rotate(rotateAngle)
ctx.globalAlpha = opacity * 0.28 * fade
ctx.strokeStyle = mt.glowColor
ctx.lineWidth = Math.max(1.2, hs * 0.028)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = isIOS ? 0 : 8 * SHADOW_MUL
drawHeartPath(ctx, 0, 0, hs)
ctx.stroke()
ctx.globalAlpha = opacity * 0.16 * fade
ctx.lineWidth = Math.max(0.8, hs * 0.012)
ctx.shadowBlur = isIOS ? 0 : 3 * SHADOW_MUL
drawHeartPath(ctx, 0, 0, hs * 0.92)
ctx.stroke()
ctx.restore()
}
drawInterlockHeart(-ox, -0.18 + breath)
drawInterlockHeart(ox, 0.18 - breath)
ctx.restore()
}
if (mt.orb && opacity > 0.18) {
const numOrbs = mt.showInterlockHearts
? (isIOS ? 8 : 12)
: (mt.showHeartOutline ? 10 : 8)
const orbRadius = fontSize * (mt.showInterlockHearts ? 1.45 : 1.0 + 0.12 * glowIntensity)
const age = (now - mt.birthTime) / 1000
for (let j = 0; j < numOrbs; j += 1) {
const angle = age * (mt.showInterlockHearts ? 0.85 : 0.7) + j * ((Math.PI * 2) / numOrbs)
const orbX = cx + Math.cos(angle) * orbRadius
const orbY = cy + Math.sin(angle) * orbRadius * 0.66
const orbSize = fontSize * 0.028 * (1 + Math.sin(age * 1.6 + j) * 0.28)
ctx.save()
ctx.globalAlpha = opacity * (mt.showInterlockHearts ? 0.18 : 0.28 + Math.sin(age * 1.4 + j) * 0.14)
ctx.fillStyle = mt.glowColor
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = isIOS ? 0 : orbSize * 4
ctx.beginPath()
ctx.arc(orbX, orbY, Math.max(1.5, orbSize), 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
}
// 书法文字(最上层)
ctx.save()
ctx.globalAlpha = opacity
ctx.translate(cx, cy)
@@ -1164,20 +1254,20 @@ function animate(timestamp) {
const showMult = mt.countRevealed && mt.count > 1
const mainY = showMult ? -fontSize * 0.42 : 0
drawMilestoneLine(
ctx,
mt.baseText || mt.text,
fontSize,
glowBlur,
mt.gradient,
mt.strokeColor,
mt.glowColor,
mainY,
{
underStroke: mt.underStroke,
charGap: mt.charGap,
birthTime: mt.birthTime,
now,
},
ctx,
mt.baseText || mt.text,
fontSize,
glowBlur,
mt.gradient,
mt.strokeColor,
mt.glowColor,
mainY,
{
underStroke: mt.underStroke,
charGap: mt.charGap,
birthTime: mt.birthTime,
now,
},
)
if (showMult) {
const n = scrolledCount(mt, now)
@@ -1189,143 +1279,24 @@ function animate(timestamp) {
ctx.translate(0, multY)
ctx.scale(pop, pop)
drawMilestoneLine(
ctx,
`×${n}`,
multSize,
glowBlur * 0.7,
mt.gradient,
mt.strokeColor,
mt.glowColor,
0,
{
underStroke: mt.underStroke,
charGap: 0.04,
birthTime: mt.countRevealAt || now,
now,
},
ctx,
`×${n}`,
multSize,
glowBlur * 0.7,
mt.gradient,
mt.strokeColor,
mt.glowColor,
0,
{
underStroke: mt.underStroke,
charGap: 0.04,
birthTime: mt.countRevealAt || now,
now,
},
)
ctx.restore()
}
ctx.restore()
// 核心心形轮廓重构:将难看的单线条换成多层高质感的霓虹流光轮廓
if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) {
const outlineScale = heartOutlineScale(mt, now)
const outlineOpacity = heartOutlineOpacity(mt, now) * (ENABLE_NEON_GLOW ? 1.0 : 0.7)
const outlineSize = fontSize * 1.55 * outlineScale
ctx.save()
ctx.translate(cx, cy)
if (ENABLE_NEON_GLOW) {
// [新增] 特效三:惊艳的霓虹流光多层绘制
ctx.globalCompositeOperation = 'lighter'
// 渲染 3 层不同的发光和线宽来模拟极具科技感和仙气的霓虹能量管
const neonLayers = [
{ widthRatio: 0.045, alpha: 0.25, blur: 20 * SHADOW_MUL }, // 底层弥散光晕
{ widthRatio: 0.02, alpha: 0.6, blur: 10 * SHADOW_MUL }, // 中层亮色核心
{ widthRatio: 0.006, alpha: 1.0, blur: 2 * SHADOW_MUL } // 顶层高亮白心
]
neonLayers.forEach((layer, idx) => {
ctx.globalAlpha = outlineOpacity * layer.alpha
// 顶层使用纯白,底层使用礼物的主题发光色
ctx.strokeStyle = idx === 2 ? '#ffffff' : mt.glowColor
ctx.lineWidth = Math.max(1, outlineSize * layer.widthRatio)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = layer.blur
drawHeartPath(ctx, 0, 0, outlineSize)
ctx.stroke()
})
} else {
// 降级旧方案(普通单线条描边)
ctx.globalAlpha = outlineOpacity
ctx.strokeStyle = mt.glowColor
ctx.lineWidth = Math.max(1, outlineSize * 0.022)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = (isIOS ? 0 : outlineSize * 0.16) * SHADOW_MUL
drawHeartPath(ctx, 0, 0, outlineSize)
ctx.stroke()
}
ctx.restore()
}
// 永结同心(双心相扣)重构:同样支持霓虹发光材质
if (mt.showInterlockHearts && opacity > 0.12) {
const p = textProgress(mt, now)
const grow = easeOutCubic(Math.min(1, p / 0.28))
const fade = p > 0.78 ? Math.max(0, 1 - (p - 0.78) / 0.22) : 1
const breath = Math.sin((now - mt.birthTime) / 900) * 0.04
const hs = fontSize * (1.05 + 0.28 * grow) * scale
const ox = fontSize * 0.38 * scale
ctx.save()
ctx.translate(cx, cy)
const drawInterlockHeart = (offsetX, rotateAngle) => {
ctx.save()
ctx.translate(offsetX, 0)
ctx.rotate(rotateAngle)
if (ENABLE_NEON_GLOW) {
ctx.globalCompositeOperation = 'lighter'
const neonLayers = [
{ widthRatio: 0.045, alpha: 0.2, blur: 15 * SHADOW_MUL },
{ widthRatio: 0.02, alpha: 0.5, blur: 8 * SHADOW_MUL },
{ widthRatio: 0.006, alpha: 0.9, blur: 2 * SHADOW_MUL }
]
neonLayers.forEach((layer, idx) => {
ctx.globalAlpha = opacity * fade * layer.alpha
ctx.strokeStyle = idx === 2 ? '#ffffff' : mt.glowColor
ctx.lineWidth = Math.max(1, hs * layer.widthRatio)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = layer.blur
drawHeartPath(ctx, 0, 0, hs)
ctx.stroke()
})
} else {
ctx.globalAlpha = opacity * 0.38 * fade
ctx.strokeStyle = mt.glowColor
ctx.lineWidth = Math.max(1, hs * 0.022)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = isIOS ? 0 : 6 * SHADOW_MUL
drawHeartPath(ctx, 0, 0, hs)
ctx.stroke()
}
ctx.restore()
}
// 绘制左心和右心
drawInterlockHeart(-ox, -0.2 + breath)
drawInterlockHeart(ox, 0.2 - breath)
ctx.restore()
}
if (mt.orb && opacity > 0.18) {
const numOrbs = mt.showInterlockHearts
? (isIOS ? 8 : 12)
: (mt.showHeartOutline ? 10 : 8)
const orbRadius = fontSize * (mt.showInterlockHearts ? 1.15 : 1.0 + 0.12 * glowIntensity)
const age = (now - mt.birthTime) / 1000
for (let j = 0; j < numOrbs; j += 1) {
const angle = age * (mt.showInterlockHearts ? 0.85 : 0.7) + j * ((Math.PI * 2) / numOrbs)
const orbX = cx + Math.cos(angle) * orbRadius
const orbY = cy + Math.sin(angle) * orbRadius * 0.66
const orbSize = fontSize * 0.028 * (1 + Math.sin(age * 1.6 + j) * 0.28)
ctx.save()
ctx.globalAlpha = opacity * (0.28 + Math.sin(age * 1.4 + j) * 0.14)
ctx.fillStyle = mt.glowColor
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = isIOS ? 0 : orbSize * 4
ctx.beginPath()
ctx.arc(orbX, orbY, Math.max(1.5, orbSize), 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
}
}
// 爆闪:中心高亮 + 全屏一层,保证冲击感

View File

@@ -18,6 +18,75 @@ export const MOBILE_LAYOUTS = [
{ value: 'landscape', label: '横屏展示', desc: '手机端强制左右分栏' },
]
/** 后台 9×9 快速选点边长 */
export const HERO_FOCUS_GRID = 9
/** 旧版命名焦点 → 百分比(兼容已保存配置) */
const LEGACY_HERO_FOCUS = {
'top-left': [0, 0],
top: [50, 0],
'top-right': [100, 0],
left: [0, 50],
center: [50, 50],
right: [100, 50],
'bottom-left': [0, 100],
bottom: [50, 100],
'bottom-right': [100, 100],
}
export function clampHeroFocus(n, fallback = 50) {
const v = Number(n)
if (!Number.isFinite(v)) return fallback
return Math.min(100, Math.max(0, Math.round(v * 10) / 10))
}
/** 生成 9×9 网格点 {x,y,key} */
export function heroFocusGridPoints(size = HERO_FOCUS_GRID) {
const n = Math.max(2, size | 0)
const pts = []
for (let row = 0; row < n; row += 1) {
for (let col = 0; col < n; col += 1) {
const x = (col / (n - 1)) * 100
const y = (row / (n - 1)) * 100
pts.push({
key: `${row}-${col}`,
x: clampHeroFocus(x),
y: clampHeroFocus(y),
})
}
}
return pts
}
/**
* object-position CSS
* - heroFocusCss(cfg) 读 cfg.heroFocusX/Y
* - heroFocusCss(x, y) 直接传百分比
* - heroFocusCss('center') 兼容旧字符串
*/
export function heroFocusCss(a, b) {
if (a && typeof a === 'object') {
return `${clampHeroFocus(a.heroFocusX, 50)}% ${clampHeroFocus(a.heroFocusY, 50)}%`
}
if (typeof a === 'string' && LEGACY_HERO_FOCUS[a]) {
const [x, y] = LEGACY_HERO_FOCUS[a]
return `${x}% ${y}%`
}
return `${clampHeroFocus(a, 50)}% ${clampHeroFocus(b, 50)}%`
}
export function resolveHeroFocusXY(loaded = {}) {
const hasXY = Number.isFinite(Number(loaded.heroFocusX)) && Number.isFinite(Number(loaded.heroFocusY))
if (hasXY) {
return {
heroFocusX: clampHeroFocus(loaded.heroFocusX),
heroFocusY: clampHeroFocus(loaded.heroFocusY),
}
}
const legacy = LEGACY_HERO_FOCUS[loaded.heroFocus] || LEGACY_HERO_FOCUS.center
return { heroFocusX: legacy[0], heroFocusY: legacy[1] }
}
/** 解析为 1 | 2 | 3无法识别返回 0 */
export function resolvePosterSide(input) {
if (input === 1 || input === '1') return 1
@@ -47,6 +116,9 @@ function baseFields(side) {
mobileLayout: 'auto',
enabled: true,
heroImg: '',
/** 主图焦点百分比 0100object-position */
heroFocusX: 50,
heroFocusY: 50,
/** 二维码中心图;空则显示圆圈大囍 */
qrCenterImg: '',
title: '婚礼邀请函',
@@ -269,6 +341,7 @@ export function normalizePosterConfig(raw, side = 1) {
pageTitle: loaded.pageTitle || base.pageTitle || '',
pageDescription: loaded.pageDescription || base.pageDescription || '',
qrCenterImg: loaded.qrCenterImg || '',
...resolveHeroFocusXY(loaded),
verticalSlogan: loaded.verticalSlogan || base.verticalSlogan,
subSlogan1: loaded.subSlogan1 || base.subSlogan1,
subSlogan2: loaded.subSlogan2 || base.subSlogan2,

View File

@@ -608,6 +608,45 @@
:uploading="uploading"
/>
</a-form-item>
<a-form-item label="照片展示焦点">
<div
v-if="posterCfg.heroImg"
ref="heroFocusPreviewEl"
class="hero-focus-preview hero-focus-preview--drag"
:class="{ dragging: heroFocusDragging }"
@pointerdown="onHeroFocusPointerDown"
>
<img
:src="posterCfg.heroImg"
alt=""
draggable="false"
:style="{ objectPosition: heroFocusCss(posterCfg) }"
/>
<span
class="hero-focus-crosshair"
:style="{ left: posterCfg.heroFocusX + '%', top: posterCfg.heroFocusY + '%' }"
/>
</div>
<div v-else class="hero-focus-preview hero-focus-preview--empty">
请先上传海报图片再拖拽设置焦点
</div>
<div class="hero-focus-pad mt-2" :style="{ '--hero-focus-n': HERO_FOCUS_GRID }">
<button
v-for="pt in heroFocusGrid"
:key="pt.key"
type="button"
class="hero-focus-cell"
:class="{ active: isHeroFocusGridActive(pt) }"
:title="`${Math.round(pt.x)}%, ${Math.round(pt.y)}%`"
@click="setHeroFocus(pt.x, pt.y)"
>
<span class="hero-focus-dot" />
</button>
</div>
<div class="text-[11px] text-[#8A8680] mt-1">
在预览上拖拽十字准星或点下方 9×9 网格当前 {{ Math.round(posterCfg.heroFocusX) }}% · {{ Math.round(posterCfg.heroFocusY) }}%
</div>
</a-form-item>
<a-form-item label="二维码中心图">
<image-field
v-model="posterCfg.qrCenterImg"
@@ -1041,7 +1080,8 @@ import { GIFT_TYPES, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftType
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_TEMPLATES, MOBILE_LAYOUTS, sideKey, posterSideMeta,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_TEMPLATES, MOBILE_LAYOUTS,
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, posterSideMeta,
} from '@/composables/posterDefaults'
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
@@ -1147,6 +1187,58 @@ const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('1')
const posterBundle = reactive(defaultPosterBundle())
const posterCfg = computed(() => posterBundle[sideKey(posterSideTab.value)] || posterBundle['1'])
const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
const heroFocusPreviewEl = ref(null)
const heroFocusDragging = ref(false)
function setHeroFocus(x, y) {
const cfg = posterCfg.value
if (!cfg) return
cfg.heroFocusX = clampHeroFocus(x)
cfg.heroFocusY = clampHeroFocus(y)
}
function isHeroFocusGridActive(pt) {
const cfg = posterCfg.value
if (!cfg) return false
return Math.abs(cfg.heroFocusX - pt.x) < 0.6
&& Math.abs(cfg.heroFocusY - pt.y) < 0.6
}
function heroFocusFromClient(clientX, clientY) {
const el = heroFocusPreviewEl.value
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const x = ((clientX - rect.left) / rect.width) * 100
const y = ((clientY - rect.top) / rect.height) * 100
setHeroFocus(x, y)
}
function onHeroFocusPointerMove(e) {
if (!heroFocusDragging.value) return
heroFocusFromClient(e.clientX, e.clientY)
}
function onHeroFocusPointerUp() {
if (!heroFocusDragging.value) return
heroFocusDragging.value = false
window.removeEventListener('pointermove', onHeroFocusPointerMove)
window.removeEventListener('pointerup', onHeroFocusPointerUp)
window.removeEventListener('pointercancel', onHeroFocusPointerUp)
}
function onHeroFocusPointerDown(e) {
if (!posterCfg.value?.heroImg) return
e.preventDefault()
heroFocusDragging.value = true
heroFocusFromClient(e.clientX, e.clientY)
window.addEventListener('pointermove', onHeroFocusPointerMove)
window.addEventListener('pointerup', onHeroFocusPointerUp)
window.addEventListener('pointercancel', onHeroFocusPointerUp)
}
const posterMusicSelectOptions = computed(() => {
const list = Array.isArray(posterMusicOptions.value) ? posterMusicOptions.value : []
const opts = list
@@ -2048,6 +2140,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', onResize)
onHeroFocusPointerUp()
disposeVisitCharts()
})
</script>
@@ -2407,4 +2500,81 @@ onUnmounted(() => {
width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.hero-focus-pad {
display: grid;
grid-template-columns: repeat(var(--hero-focus-n, 9), 22px);
gap: 4px;
width: fit-content;
padding: 8px;
border-radius: 10px;
background: #f7f4ef;
border: 1px solid #e7e3db;
}
.hero-focus-cell {
width: 22px;
height: 22px;
border-radius: 5px;
border: 1px solid #ddd6cb;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: border-color 0.15s, background 0.15s;
}
.hero-focus-cell:hover { border-color: #a88c6b; }
.hero-focus-cell.active {
border-color: #a88c6b;
background: rgba(168, 140, 107, 0.14);
}
.hero-focus-dot {
width: 5px;
height: 5px;
border-radius: 999px;
background: #c4b8a4;
}
.hero-focus-cell.active .hero-focus-dot { background: #a88c6b; }
.hero-focus-preview {
position: relative;
width: min(100%, 280px);
aspect-ratio: 16 / 11;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e7e3db;
background: #f0ebe3;
user-select: none;
touch-action: none;
}
.hero-focus-preview--drag { cursor: crosshair; }
.hero-focus-preview--drag.dragging { cursor: grabbing; }
.hero-focus-preview--empty {
display: flex;
align-items: center;
justify-content: center;
color: #8A8680;
font-size: 12px;
padding: 12px;
text-align: center;
}
.hero-focus-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.hero-focus-crosshair {
position: absolute;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border: 2px solid #fff;
border-radius: 999px;
box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.85), 0 1px 4px rgba(0, 0, 0, 0.25);
background: rgba(168, 140, 107, 0.35);
pointer-events: none;
z-index: 1;
}
</style>

View File

@@ -50,6 +50,18 @@
<div v-else class="hb-fade-dot"></div>
</div>
<!-- 等待揭幕音乐失败需轻触音乐成功也会在此停留片刻后自动淡出 -->
<div
v-else-if="phase === 'waiting'"
class="hb-waiting"
@click="openPoster"
>
<div v-if="cfg.loadingEffect === 'redSeal'" class="hb-seal"></div>
<div v-else-if="cfg.loadingEffect === 'goldShimmer'" class="hb-shimmer">婚礼邀请函</div>
<div v-else class="hb-fade-dot"></div>
<div class="hb-open-hint"><span class="dot"></span>轻触打开请柬</div>
</div>
<div
v-if="phase === 'enter'"
class="hb-enter"
@@ -93,12 +105,12 @@
</aside>
<button
v-if="hasMusic && phase !== 'loading'"
v-if="hasMusic && (phase === 'waiting' || phase === 'enter' || phase === 'content')"
type="button"
class="hb-music-fab"
:class="{ 'is-playing': audio.isPlaying.value }"
:title="audio.isPlaying.value ? '暂停音乐' : '播放音乐'"
@click="audio.toggle()"
@click.stop="audio.toggle()"
>
<i class="fa-solid" :class="audio.isPlaying.value ? 'fa-pause' : 'fa-music'"></i>
</button>
@@ -134,6 +146,10 @@ const isPc = ref(false)
const pageUrl = computed(() => (typeof window !== 'undefined' ? window.location.href : ''))
const audio = useAudio()
const hasMusic = computed(() => !!String(cfg.musicUrl || '').trim())
/** 与请柬一致:有音乐且自动播放成功后延迟自动揭幕 */
const POSTER_AUTO_OPEN_MS = 3600
let posterOpening = false
let autoOpenTimer = null
let timers = []
function later(fn, ms) {
@@ -256,9 +272,10 @@ async function savePosterImage() {
}
}
/** @returns {Promise<boolean>} 无音乐视为成功;有音乐则返回是否自动播放成功 */
async function setupPosterMusic() {
const url = String(cfg.musicUrl || '').trim()
if (!url) return
if (!url) return true
let track = { url, name: '背景音乐' }
try {
const res = await getConfig()
@@ -269,8 +286,34 @@ async function setupPosterMusic() {
/* 仅用 poster 上的 url 亦可播放 */
}
audio.setTracks([track], url)
const ok = await audio.attemptAutoplay()
if (!ok) audio.armAutoplay()
return audio.attemptAutoplay()
}
/** 执行海报淡出揭幕动画enter → content */
async function playPosterEnter() {
phase.value = 'enter'
contentVisible.value = false
enterLeaving.value = false
await nextTick()
const enterMs = { fadeUp: 700, scaleIn: 800, curtain: 1000 }[cfg.enterEffect] || 700
later(() => {
enterLeaving.value = true
contentVisible.value = true
}, 80)
await new Promise((r) => later(r, enterMs))
phase.value = 'content'
}
/** 打开海报:手势或自动;顺带在用户手势下再尝试播音乐 */
async function openPoster() {
if (posterOpening) return
if (phase.value !== 'waiting' && phase.value !== 'loading') return
posterOpening = true
clearTimeout(autoOpenTimer)
autoOpenTimer = null
if (hasMusic.value) audio.play()
await playPosterEnter()
}
async function runSequence() {
@@ -283,17 +326,23 @@ async function runSequence() {
return
}
phase.value = 'enter'
contentVisible.value = false
await nextTick()
const musicOk = await setupPosterMusic()
const enterMs = { fadeUp: 700, scaleIn: 800, curtain: 1000 }[cfg.enterEffect] || 700
later(() => {
enterLeaving.value = true
contentVisible.value = true
}, 80)
await new Promise((r) => later(r, enterMs))
phase.value = 'content'
if (hasMusic.value && !musicOk) {
// 与请柬一致:自动播放失败则停住,等待轻触后再揭幕
phase.value = 'waiting'
return
}
if (hasMusic.value && musicOk) {
// 有音乐且已播出:短暂停留后自动淡出揭幕
autoOpenTimer = later(() => openPoster(), POSTER_AUTO_OPEN_MS)
phase.value = 'waiting'
return
}
// 未配置音乐:直接揭幕
await openPoster()
}
onMounted(async () => {
@@ -307,13 +356,14 @@ onMounted(async () => {
Object.assign(cfg, normalizePosterConfig(null, side))
}
applyPageMeta()
setupPosterMusic()
runSequence()
})
onUnmounted(() => {
timers.forEach(clearTimeout)
timers = []
clearTimeout(autoOpenTimer)
autoOpenTimer = null
window.removeEventListener('resize', refreshPc)
})
</script>
@@ -369,6 +419,38 @@ onUnmounted(() => {
pointer-events: none;
}
.hb-pc-tools > * { pointer-events: auto; }
.hb-waiting {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
background: var(--hb-red-deep);
cursor: pointer;
}
.hb-open-hint {
display: inline-flex;
align-items: center;
gap: 8px;
color: #f0d78c;
font-size: 13px;
letter-spacing: 0.26em;
animation: hb-hint-pulse 1.6s ease-in-out infinite;
}
.hb-open-hint .dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #f0d78c;
box-shadow: 0 0 8px rgba(240, 215, 140, 0.7);
}
@keyframes hb-hint-pulse {
0%, 100% { opacity: 0.75; }
50% { opacity: 1; }
}
.hb-music-fab {
position: fixed;
left: 16px;

View File

@@ -37,7 +37,13 @@
<div class="hb-hero-wrap" :class="{ 'is-show': stage >= 1 }">
<div class="hb-hero">
<img v-if="cfg.heroImg" :src="cfg.heroImg" alt="" class="hb-hero-img" />
<img
v-if="cfg.heroImg"
:src="cfg.heroImg"
alt=""
class="hb-hero-img"
:style="{ objectPosition: heroObjectPosition }"
/>
<div v-else class="hb-hero-placeholder">婚礼照片</div>
<span class="hb-xi hb-xi-l"></span>
<span class="hb-xi hb-xi-r"></span>
@@ -137,6 +143,7 @@
import { computed, ref, watch } from 'vue'
import BlurText from '@/components/vue-bits/BlurText.vue'
import TextType from '@/components/vue-bits/TextType.vue'
import { heroFocusCss } from '@/composables/posterDefaults'
const props = defineProps({
cfg: { type: Object, required: true },
@@ -149,6 +156,7 @@ const lineStage = ref(0)
const namesRightReady = ref(false)
let timers = []
const heroObjectPosition = computed(() => heroFocusCss(props.cfg))
const nameLeft = computed(() => `${props.cfg.sonLabel || ''}${props.cfg.sonName || ''}`)
const nameRight = computed(() => `${props.cfg.daughterInLawLabel || ''}${props.cfg.daughterInLawName || ''}`)

View File

@@ -9,7 +9,12 @@
<div class="sp-card" data-poster-capture>
<section class="sp-left">
<div class="sp-photo" :class="{ 'is-show': stage >= 1 }">
<img v-if="cfg.heroImg" :src="cfg.heroImg" alt="" />
<img
v-if="cfg.heroImg"
:src="cfg.heroImg"
alt=""
:style="{ objectPosition: heroObjectPosition }"
/>
<div v-else class="sp-photo-ph">婚礼照片</div>
<div class="sp-photo-veil"></div>
</div>
@@ -115,6 +120,7 @@
import { computed, ref, watch } from 'vue'
import BlurText from '@/components/vue-bits/BlurText.vue'
import TextType from '@/components/vue-bits/TextType.vue'
import { heroFocusCss } from '@/composables/posterDefaults'
const props = defineProps({
cfg: { type: Object, required: true },
@@ -126,6 +132,8 @@ const stage = ref(0)
const lineStage = ref(0)
let timers = []
const heroObjectPosition = computed(() => heroFocusCss(props.cfg))
const bodyLines = computed(() => {
const lines = Array.isArray(props.cfg.lines) ? props.cfg.lines.filter(Boolean) : []
if (lines.length) return lines.slice(0, 4)

View File

@@ -121,7 +121,7 @@
:disabled="likeBusy"
title="点赞(长按快捷次数)"
@click="handleLikeClick"
@touchstart.passive="onLikePressStart"
@touchstart="onLikePressStart"
@touchend="onLikePressEnd"
@touchcancel="onLikePressCancel"
@mousedown="onLikePressStart"
@@ -957,6 +957,8 @@ const clearLikePressTimer = () => {
const onLikePressStart = (e) => {
if (likeBusy.value) return
if (e.type === 'mousedown' && e.button !== 0) return
// 阻止 iOS 长按选字 / 呼出菜单touch-action:manipulation 防连点放大
if (e.type === 'touchstart' && e.cancelable) e.preventDefault()
likePressFired = false
clearLikePressTimer()
likePressTimer = setTimeout(() => {
@@ -970,16 +972,25 @@ const onLikePressStart = (e) => {
}, 420)
}
const onLikePressEnd = () => {
const onLikePressEnd = (e) => {
const longPress = likePressFired
clearLikePressTimer()
// touchstart 已 preventDefault 时,部分机型不会再派发 click短按在此补一次
if (e?.type === 'touchend' && !longPress) {
likeSkipClick = true
handleLike(1)
return
}
if (longPress) likeSkipClick = true
}
const onLikePressCancel = () => {
clearLikePressTimer()
}
const handleLikeClick = () => {
if (likeSkipClick || likePressFired) {
const handleLikeClick = (e) => {
// 触摸已在 touchend 处理,忽略随后的合成 click
if (e?.pointerType === 'touch' || likeSkipClick || likePressFired) {
likeSkipClick = false
likePressFired = false
return
@@ -1714,7 +1725,24 @@ const applyGiftCounts = (counts, total) => {
giftTotal.value = Number(total) || Object.values(next).reduce((a, b) => a + b, 0)
}
let giftPanelCloseTimer = null
const GIFT_PANEL_IDLE_CLOSE_MS = 800
const clearGiftPanelCloseTimer = () => {
clearTimeout(giftPanelCloseTimer)
giftPanelCloseTimer = null
}
const scheduleGiftPanelClose = () => {
clearGiftPanelCloseTimer()
giftPanelCloseTimer = setTimeout(() => {
giftPanelCloseTimer = null
closeGiftPanel()
}, GIFT_PANEL_IDLE_CLOSE_MS)
}
const openGiftPanel = async () => {
clearGiftPanelCloseTimer()
showLikeBubbles.value = false
syncGuestNameIntoForms()
giftForm.name = getGuestName() || giftForm.name
@@ -1744,14 +1772,21 @@ const onGiftPlayStart = ({ giftType, name, count, phase }) => {
}
const closeGiftPanel = () => {
clearGiftPanelCloseTimer()
showGiftPanel.value = false
}
const handleSendGift = async (giftType) => {
if (giftBusy.value) return
// 点了任意礼物就延后关闭;忙碌中也续上 800ms方便连送
clearGiftPanelCloseTimer()
if (giftBusy.value) {
scheduleGiftPanelClose()
return
}
const cost = giftCostOf(giftType)
if (giftQuota.available < cost) {
const need = cost - giftQuota.available
scheduleGiftPanelClose()
return toast.error(`点赞不足,再点赞 ${need} 次可兑换`)
}
const name = giftForm.name.trim()
@@ -1767,11 +1802,12 @@ const handleSendGift = async (giftType) => {
syncGuestNameIntoForms()
applyGiftCounts(res.data?.counts, res.data?.total)
applyGiftQuota(res.data?.quota)
closeGiftPanel()
if (showGiftFx.value) {
giftEffectRef.value?.play({ giftType, name, count: 1 })
}
toast.success('心意已送达')
// 800ms 内再点其他礼物则继续送;否则自动收起面板
scheduleGiftPanelClose()
} catch (e) {
toast.error(e.message || '送礼失败')
try {
@@ -1933,6 +1969,7 @@ onUnmounted(() => {
clearTimeout(likePressTimer)
clearTimeout(likeBubblesHideTimer)
clearTimeout(likePulseTimer)
clearGiftPanelCloseTimer()
clearBottomReturn()
clearBlessingAutoClose()
})
@@ -2037,6 +2074,10 @@ watch(
justify-content: center;
z-index: 3;
overflow: visible;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.like-bubbles {
position: absolute;
@@ -2095,6 +2136,12 @@ watch(
color: #a88c6b;
box-shadow: 0 6px 18px rgba(92, 74, 53, 0.08);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
/* 防长按选字 / iOS 连点双击放大 */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.action-pill:active { transform: scale(0.97); }
.action-pill-icon { width: 18px; height: 18px; display: block; }