1. 礼物特效

This commit is contained in:
李琦
2026-08-03 13:28:26 +08:00
parent 43957f6547
commit e9410c47a9
2 changed files with 127 additions and 39 deletions

View File

@@ -1,5 +1,7 @@
<template>
<canvas ref="canvasRef" class="gift-effect-canvas" aria-hidden="true" />
<Teleport to="body">
<canvas ref="canvasRef" class="gift-effect-canvas" aria-hidden="true" />
</Teleport>
</template>
<script setup>
@@ -252,12 +254,22 @@ function resizeCanvas() {
const canvas = canvasRef.value
if (!canvas) return
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
// iOS优先用 visualViewport,避免地址栏伸缩导致尺寸错乱
const vv = typeof window !== 'undefined' ? window.visualViewport : null
const cssW = Math.max(1, Math.floor((vv && vv.width) || window.innerWidth || canvas.clientWidth || 1))
const cssH = Math.max(1, Math.floor((vv && vv.height) || window.innerHeight || canvas.clientHeight || 1))
// 用布局视口铺满全屏。iOS visualViewport 常窄于整屏且有 offset
// 再叠 wrapper overflow 裁切,会出现「只盖住左侧一块」的灰幕。
const cssW = Math.max(
1,
Math.floor(window.innerWidth || 0),
Math.floor(document.documentElement?.clientWidth || 0),
)
const cssH = Math.max(
1,
Math.floor(window.innerHeight || 0),
Math.floor(document.documentElement?.clientHeight || 0),
)
W = cssW
H = cssH
canvas.style.left = '0px'
canvas.style.top = '0px'
canvas.style.width = `${cssW}px`
canvas.style.height = `${cssH}px`
canvas.style.writingMode = 'horizontal-tb'
@@ -819,7 +831,7 @@ function playOne(item) {
const payload = normalizePlayItem(item)
if (!payload) return 0
const { giftType, name, count } = payload
if (!W || !H) resizeCanvas()
resizeCanvas()
clearVisuals()
const now = performance.now()
const style = GIFT_STYLE[giftType]
@@ -1340,7 +1352,6 @@ onMounted(() => {
}
window.addEventListener('resize', onResize)
window.visualViewport?.addEventListener('resize', onResize)
window.visualViewport?.addEventListener('scroll', onResize)
})
onUnmounted(() => {
@@ -1353,7 +1364,6 @@ onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId)
window.removeEventListener('resize', onResize)
window.visualViewport?.removeEventListener('resize', onResize)
window.visualViewport?.removeEventListener('scroll', onResize)
texts = []
particles = []
rings = []
@@ -1379,9 +1389,11 @@ defineExpose({ play, playSequence, clear: clearAll, stop: clearAll })
<style scoped>
.gift-effect-canvas {
position: fixed;
inset: 0;
left: 0;
top: 0;
width: 100%;
width: 100vw;
width: 100dvw;
height: 100%;
height: 100dvh;
z-index: 46;
@@ -1391,9 +1403,9 @@ defineExpose({ play, playSequence, clear: clearAll, stop: clearAll })
text-orientation: mixed;
direction: ltr;
white-space: nowrap;
/* iOS独立合成层,避免和左侧点赞 canvas / transform 滚动打架 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
/* 独立合成层;挂到 body 后不再受请柬 wrapper overflow 裁切 */
transform: translate3d(0, 0, 0);
-webkit-transform: translate3d(0, 0, 0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}

View File

@@ -372,6 +372,44 @@
</div>
</div>
<!-- 送礼未填姓名弹出姓名模态框 -->
<Teleport to="body">
<div
v-if="showGiftNameModal"
class="fixed inset-0 z-[80] bg-black/40 flex items-center justify-center px-5"
@click.self="closeGiftNameModal"
>
<div class="gift-name-modal w-full max-w-[360px] bg-white rounded-2xl shadow-2xl px-5 pt-5 pb-4">
<div class="text-center mb-4">
<div class="text-[15px] text-[#2c2c2c] tracking-[0.12em]">留下您的姓名</div>
<div class="text-[11px] text-[#8A8680] mt-1.5 leading-relaxed">送礼需要署名方便新人知道心意来自谁</div>
</div>
<input
ref="giftNameInputRef"
v-model="giftNameDraft"
type="text"
maxlength="20"
placeholder="您的姓名"
class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/35 rounded-xl px-4 py-3 text-[14px] focus:outline-none focus:border-[#a88c6b]"
@keyup.enter="confirmGiftName"
/>
<div class="flex gap-2 mt-4">
<button
type="button"
class="flex-1 py-3 rounded-full text-[12px] tracking-[0.2em] border border-[#a88c6b]/30 text-[#8A8680]"
@click="closeGiftNameModal"
>取消</button>
<button
type="button"
class="flex-1 py-3 rounded-full text-[12px] tracking-[0.2em] bg-[#a88c6b] text-white"
:disabled="giftBusy"
@click="confirmGiftName"
>确认送礼</button>
</div>
</div>
</div>
</Teleport>
<!-- 主滚动区域注意不要加 scroll-smooth它会劫持 JS 滚动赋值导致卡顿 -->
<!-- iOS 自由自动滚时对该节点做 translate3d避免每帧写 scrollTop 与系统合成器打架 -->
<div ref="scrollContainerRef"
@@ -886,6 +924,10 @@ const giftTotal = ref(0)
const giftBusy = ref(false)
const showGiftPanel = ref(false)
const giftForm = reactive({ name: '' })
const showGiftNameModal = ref(false)
const giftNameDraft = ref('')
const giftNameInputRef = ref(null)
const pendingGiftType = ref('')
const giftQuota = reactive({
my_likes: 0,
spent: 0,
@@ -1776,6 +1818,62 @@ const closeGiftPanel = () => {
showGiftPanel.value = false
}
const closeGiftNameModal = () => {
showGiftNameModal.value = false
pendingGiftType.value = ''
giftNameDraft.value = ''
}
const openGiftNameModal = (giftType) => {
pendingGiftType.value = giftType
giftNameDraft.value = giftForm.name.trim() || getGuestName() || ''
showGiftNameModal.value = true
nextTick(() => {
giftNameInputRef.value?.focus?.()
})
}
const sendGiftRequest = async (giftType, name) => {
giftBusy.value = true
try {
const res = await submitGift({
giftType,
name,
clientId: getClientId(),
})
setGuestName(name)
giftForm.name = name
syncGuestNameIntoForms()
applyGiftCounts(res.data?.counts, res.data?.total)
applyGiftQuota(res.data?.quota)
if (showGiftFx.value) {
giftEffectRef.value?.play({ giftType, name, count: 1 })
}
toast.success('心意已送达')
scheduleGiftPanelClose()
} catch (e) {
toast.error(e.message || '送礼失败')
try {
const res = await getGiftStatus(getClientId())
applyGiftQuota(res.data?.quota)
} catch { /* ignore */ }
} finally {
giftBusy.value = false
}
}
const confirmGiftName = async () => {
const name = giftNameDraft.value.trim()
if (!name) return toast.error('请填写姓名')
const giftType = pendingGiftType.value
showGiftNameModal.value = false
pendingGiftType.value = ''
giftNameDraft.value = ''
if (!giftType) return
clearGiftPanelCloseTimer()
await sendGiftRequest(giftType, name)
}
const handleSendGift = async (giftType) => {
// 点了任意礼物就延后关闭;忙碌中也续上 800ms方便连送
clearGiftPanelCloseTimer()
@@ -1789,34 +1887,12 @@ const handleSendGift = async (giftType) => {
scheduleGiftPanelClose()
return toast.error(`点赞不足,再点赞 ${need} 次可兑换`)
}
const name = giftForm.name.trim()
if (!name) return toast.error('请填写姓名')
giftBusy.value = true
try {
const res = await submitGift({
giftType,
name,
clientId: getClientId(),
})
setGuestName(name)
syncGuestNameIntoForms()
applyGiftCounts(res.data?.counts, res.data?.total)
applyGiftQuota(res.data?.quota)
if (showGiftFx.value) {
giftEffectRef.value?.play({ giftType, name, count: 1 })
}
toast.success('心意已送达')
// 800ms 内再点其他礼物则继续送;否则自动收起面板
scheduleGiftPanelClose()
} catch (e) {
toast.error(e.message || '送礼失败')
try {
const res = await getGiftStatus(getClientId())
applyGiftQuota(res.data?.quota)
} catch { /* ignore */ }
} finally {
giftBusy.value = false
const name = giftForm.name.trim() || getGuestName()
if (!name) {
openGiftNameModal(giftType)
return
}
await sendGiftRequest(giftType, name)
}
const loadLikeStatus = async () => {