1. 礼物特效
This commit is contained in:
@@ -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="💕李琦❤️李逸烁 💍💒婚礼请柬" />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 爆闪:中心高亮 + 全屏一层,保证冲击感
|
||||
|
||||
@@ -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: '',
|
||||
/** 主图焦点百分比 0–100(object-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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || ''}`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user