250 lines
6.0 KiB
Vue
250 lines
6.0 KiB
Vue
<template>
|
||
<div class="gift-feed" aria-live="polite">
|
||
<TransitionGroup name="gift-feed-item" tag="div" class="gift-feed-list">
|
||
<div
|
||
v-for="item in items"
|
||
:key="item.id"
|
||
class="gift-feed-row"
|
||
>
|
||
<span class="gift-feed-name">{{ item.name }}</span>
|
||
<span class="gift-feed-verb">送出</span>
|
||
<span class="gift-feed-gift-wrap">
|
||
<span class="gift-feed-gift calligraphy-strong">{{ item.label }}</span>
|
||
<span
|
||
v-if="item.showCount"
|
||
class="gift-feed-mult calligraphy-strong"
|
||
>×{{ item.displayCount }}</span>
|
||
</span>
|
||
</div>
|
||
</TransitionGroup>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onUnmounted } from 'vue'
|
||
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
|
||
|
||
const MAX_ITEMS = 5
|
||
const LIFE_MS = 8000
|
||
const ROLL_MS = 900
|
||
const items = ref([])
|
||
const timers = new Map()
|
||
const rollTimers = new Map()
|
||
let seq = 0
|
||
|
||
const baseLabel = (giftType) => GIFT_LABEL_MAP[giftType] || giftType
|
||
|
||
const removeItem = (id) => {
|
||
const t = timers.get(id)
|
||
if (t) {
|
||
clearTimeout(t)
|
||
timers.delete(id)
|
||
}
|
||
const r = rollTimers.get(id)
|
||
if (r) {
|
||
cancelAnimationFrame(r)
|
||
rollTimers.delete(id)
|
||
}
|
||
items.value = items.value.filter((it) => it.id !== id)
|
||
}
|
||
|
||
const resetLife = (id) => {
|
||
const old = timers.get(id)
|
||
if (old) clearTimeout(old)
|
||
timers.set(id, setTimeout(() => removeItem(id), LIFE_MS))
|
||
}
|
||
|
||
/**
|
||
* 追加一条:主特效阶段只显示礼物名;×N 稍后通过 revealCount 补上
|
||
*/
|
||
const push = (name, giftType, count = 1) => {
|
||
const n = String(name || '').trim() || '匿名'
|
||
const type = String(giftType || '').trim()
|
||
if (!type) return ''
|
||
const c = Math.max(1, Number(count) || 1)
|
||
seq += 1
|
||
const id = `${Date.now()}-${seq}`
|
||
const next = {
|
||
id,
|
||
name: n,
|
||
giftType: type,
|
||
count: c,
|
||
label: baseLabel(type),
|
||
showCount: false,
|
||
displayCount: 1,
|
||
}
|
||
while (items.value.length >= MAX_ITEMS) {
|
||
removeItem(items.value[0].id)
|
||
}
|
||
items.value = [...items.value, next]
|
||
resetLife(id)
|
||
return id
|
||
}
|
||
|
||
const animateRoll = (id, target, fromOverride) => {
|
||
const it0 = items.value.find((x) => x.id === id)
|
||
if (!it0) return
|
||
const from = fromOverride != null ? fromOverride : (it0.displayCount || 1)
|
||
it0.displayCount = from
|
||
items.value = [...items.value]
|
||
const start = performance.now()
|
||
const tick = (now) => {
|
||
const it = items.value.find((x) => x.id === id)
|
||
if (!it) {
|
||
rollTimers.delete(id)
|
||
return
|
||
}
|
||
const t = Math.min(1, (now - start) / ROLL_MS)
|
||
const e = 1 - (1 - t) ** 3
|
||
it.displayCount = Math.max(from, Math.round(from + (target - from) * e))
|
||
items.value = [...items.value]
|
||
if (t < 1) {
|
||
rollTimers.set(id, requestAnimationFrame(tick))
|
||
} else {
|
||
it.displayCount = target
|
||
items.value = [...items.value]
|
||
rollTimers.delete(id)
|
||
}
|
||
}
|
||
const prev = rollTimers.get(id)
|
||
if (prev) cancelAnimationFrame(prev)
|
||
rollTimers.set(id, requestAnimationFrame(tick))
|
||
}
|
||
|
||
/** 同一次特效中稍后出现 ×N,并数字滚动;已显示则可继续叠加 */
|
||
const revealCount = (giftType, count) => {
|
||
const c = Math.max(1, Number(count) || 1)
|
||
if (c <= 1) return
|
||
const list = items.value
|
||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||
const it = list[i]
|
||
if (giftType && it.giftType !== giftType) continue
|
||
const from = it.showCount ? (it.displayCount || 1) : 1
|
||
it.count = c
|
||
it.showCount = true
|
||
resetLife(it.id)
|
||
items.value = [...list]
|
||
animateRoll(it.id, c, from)
|
||
return
|
||
}
|
||
}
|
||
|
||
/** 关闭特效时:连续同礼物合并到最后一条 */
|
||
const bumpOrPush = (name, giftType) => {
|
||
const n = String(name || '').trim() || '匿名'
|
||
const type = String(giftType || '').trim()
|
||
if (!type) return
|
||
const last = items.value[items.value.length - 1]
|
||
if (last && last.name === n && last.giftType === type) {
|
||
last.count += 1
|
||
last.showCount = true
|
||
resetLife(last.id)
|
||
items.value = [...items.value]
|
||
animateRoll(last.id, last.count, last.displayCount || 1)
|
||
return last.id
|
||
}
|
||
return push(n, type, 1)
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
timers.forEach((t) => clearTimeout(t))
|
||
timers.clear()
|
||
rollTimers.forEach((r) => cancelAnimationFrame(r))
|
||
rollTimers.clear()
|
||
})
|
||
|
||
defineExpose({ push, revealCount, bumpOrPush })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.gift-feed {
|
||
position: fixed;
|
||
left: 10px;
|
||
bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||
z-index: 48;
|
||
max-width: min(72vw, 260px);
|
||
pointer-events: none;
|
||
transform: translateZ(0);
|
||
-webkit-transform: translateZ(0);
|
||
backface-visibility: hidden;
|
||
-webkit-backface-visibility: hidden;
|
||
}
|
||
|
||
.gift-feed-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 6px;
|
||
}
|
||
|
||
.gift-feed-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 4px 6px;
|
||
max-width: 100%;
|
||
padding: 6px 10px;
|
||
border-radius: 14px;
|
||
background: rgba(70, 70, 75, 0.28);
|
||
color: rgba(255, 255, 255, 0.92);
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.gift-feed-name {
|
||
font-size: 11px;
|
||
letter-spacing: 0.04em;
|
||
color: rgba(255, 255, 255, 0.88);
|
||
max-width: 5.5em;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.gift-feed-verb {
|
||
font-size: 10px;
|
||
color: rgba(255, 255, 255, 0.55);
|
||
letter-spacing: 0.08em;
|
||
}
|
||
|
||
.gift-feed-gift-wrap {
|
||
display: inline-flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.gift-feed-gift {
|
||
font-size: 14px;
|
||
letter-spacing: 0.06em;
|
||
color: #ffe8a8;
|
||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
.gift-feed-mult {
|
||
font-size: 13px;
|
||
letter-spacing: 0.04em;
|
||
color: #fff0c2;
|
||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.gift-feed-item-enter-active {
|
||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||
}
|
||
.gift-feed-item-leave-active {
|
||
transition: opacity 0.45s ease, transform 0.45s ease;
|
||
}
|
||
.gift-feed-item-enter-from {
|
||
opacity: 0;
|
||
transform: translateY(12px);
|
||
}
|
||
.gift-feed-item-leave-to {
|
||
opacity: 0;
|
||
transform: translateX(-28px);
|
||
}
|
||
.gift-feed-item-move {
|
||
transition: transform 0.35s ease;
|
||
}
|
||
</style>
|