Files
hunli/vite-tailwindcss/src/components/GiftFeed.vue
2026-08-03 11:48:31 +08:00

250 lines
6.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>