1. 礼物特效
This commit is contained in:
@@ -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