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

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