1. 礼物特效

This commit is contained in:
李琦
2026-08-03 11:48:31 +08:00
parent ab2cafeb1b
commit 63fea7e903
6 changed files with 435 additions and 130 deletions

Binary file not shown.

View File

@@ -5,14 +5,18 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
import { isIOS } from '@/composables/useAndroidOptimize'
const emit = defineEmits(['play-start'])
const canvasRef = ref(null)
const MAX_PARTICLES = 280
const MAX_RINGS = 12
const MAX_PARTICLES = isIOS ? 100 : 280
const MAX_RINGS = isIOS ? 6 : 12
const MAX_TEXTS = 3
const DPR_CAP = 2
const DPR_CAP = isIOS ? 1.5 : 2
const SHADOW_MUL = isIOS ? 0.2 : 1
const SKIP_AURA = isIOS
const PARTICLE_SCALE = isIOS ? 0.55 : 1
const GIFT_STYLE = {
'520': {
@@ -187,17 +191,24 @@ let running = false
let playQueue = []
let queueTimer = null
let secondaryTimers = []
/** 当前正在播放的礼物(用于连续同礼物叠加 ×N */
let activeMeta = null
function resizeCanvas() {
const canvas = canvasRef.value
if (!canvas) return
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
const rect = canvas.getBoundingClientRect()
W = rect.width
H = rect.height
// 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))
W = cssW
H = cssH
canvas.style.width = `${cssW}px`
canvas.style.height = `${cssH}px`
canvas.width = Math.max(1, Math.floor(W * dpr))
canvas.height = Math.max(1, Math.floor(H * dpr))
ctx = canvas.getContext('2d')
ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) return
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
@@ -230,16 +241,18 @@ function createTextEffect(giftType, now, count = 1) {
const style = GIFT_STYLE[giftType] || GIFT_STYLE['520']
const base = GIFT_LABEL_MAP[giftType] || giftType
const n = Math.max(1, Number(count) || 1)
const text = n > 1 ? `${base}×${n}` : base
// 带倍数时略缩小字号,避免出屏
const sizeBoost = n > 1 ? 0.88 : 1
const fontSize = Math.max(12, Math.min(W * style.fontSizeRatio || 48, style.fontSizeMax))
return {
text,
text: base,
baseText: base,
giftType,
count: n,
countRevealed: false,
countRevealAt: 0,
rollFrom: 1,
birthTime: now,
duration: style.duration,
fontSize: Math.min(W * style.fontSizeRatio, style.fontSizeMax) * sizeBoost,
fontSize,
glowColor: style.glowColor,
strokeColor: style.strokeColor,
peakScale: style.peakScale,
@@ -251,6 +264,43 @@ function createTextEffect(giftType, now, count = 1) {
}
}
const COUNT_ROLL_MS = 900
/** ×N 数字滚动:支持中途叠加时从 rollFrom 滚到新目标 */
function scrolledCount(mt, now) {
const target = Math.max(1, mt.count || 1)
if (!mt.countRevealed) return 1
const from = Math.max(1, Number(mt.rollFrom) || 1)
const elapsed = now - (mt.countRevealAt || now)
const t = Math.min(1, Math.max(0, elapsed / COUNT_ROLL_MS))
const e = 1 - (1 - t) ** 3
return Math.max(from, Math.round(from + (target - from) * e))
}
function drawMilestoneLine(context, text, fontSize, glowBlur, gradient, strokeColor, glowColor, y) {
const blur = Math.max(0, glowBlur * SHADOW_MUL)
context.shadowColor = glowColor
context.shadowBlur = blur
context.strokeStyle = strokeColor
context.lineWidth = fontSize * 0.065
context.lineJoin = 'round'
context.font = `700 ${fontSize}px "Ma Shan Zheng", "Great Vibes", cursive, serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.strokeText(text, 0, y)
const textGrad = context.createLinearGradient(0, y - fontSize * 0.55, 0, y + fontSize * 0.55)
textGrad.addColorStop(0, gradient[0])
textGrad.addColorStop(0.35, gradient[1])
textGrad.addColorStop(0.7, gradient[2])
textGrad.addColorStop(1, gradient[3])
context.fillStyle = textGrad
context.shadowBlur = blur * 1.5
context.fillText(text, 0, y)
context.shadowBlur = 0
context.fillStyle = 'rgba(255,255,255,0.4)'
context.fillText(text, -fontSize * 0.018, y - fontSize * 0.028)
}
function textProgress(t, now) {
return Math.min((now - t.birthTime) / t.duration, 1)
}
@@ -350,7 +400,7 @@ function spawnParticles(giftType, now, wave = 0) {
const mode = style.particleMode
const palette = style.palette
const glowPalette = style.glowPalette
const boost = wave > 0 ? 0.75 : 1
const boost = (wave > 0 ? 0.75 : 1) * PARTICLE_SCALE
if (mode === 'heartBurst') {
const n = Math.floor(42 * boost)
@@ -541,24 +591,81 @@ function playOne(item) {
clearVisuals()
const now = performance.now()
const style = GIFT_STYLE[giftType]
texts.push(createTextEffect(giftType, now, count))
const mt = createTextEffect(giftType, now, count)
texts.push(mt)
activeMeta = { giftType, name, mt }
spawnParticles(giftType, now, 0)
spawnShockwave(W / 2, H * 0.46, now, style.glowColor, style.showHeartOutline ? 3 : 2)
flashAlpha = style.flash
flashDecay = style.flash / Math.max(1, style.flashMs / 16)
spawnShockwave(W / 2, H * 0.46, now, style.glowColor, style.showHeartOutline && !isIOS ? 3 : 2)
flashAlpha = isIOS ? Math.min(style.flash, 0.45) : style.flash
flashDecay = flashAlpha / Math.max(1, style.flashMs / 16)
flashColor = style.flashColor
if (style.secondaryBurst) {
if (style.secondaryBurst && !isIOS) {
const t = setTimeout(() => {
spawnParticles(giftType, performance.now(), 1)
ensureLoop()
}, 280)
secondaryTimers.push(t)
}
emit('play-start', { giftType, name, count })
emit('play-start', { giftType, name, count, phase: 'main' })
if (count > 1) {
const revealAt = Math.min(style.duration * 0.42, 1100)
const t = setTimeout(() => {
if (!texts.includes(mt) || mt.countRevealed) return
mt.countRevealed = true
mt.rollFrom = 1
mt.countRevealAt = performance.now()
emit('play-start', { giftType, name, count: mt.count, phase: 'mult' })
ensureLoop()
}, revealAt)
secondaryTimers.push(t)
}
ensureLoop()
return style.duration + 280
}
/** 连续送同一礼物:叠加到当前特效 / 队列末项,显示 ×N */
function tryBumpSame(payload) {
const add = Math.max(1, payload.count || 1)
// 叠加队列末尾相同项
const last = playQueue[playQueue.length - 1]
if (last && last.giftType === payload.giftType && last.name === payload.name) {
last.count += add
return true
}
// 叠加正在播放的特效
if (
activeMeta
&& activeMeta.giftType === payload.giftType
&& activeMeta.name === payload.name
&& texts.includes(activeMeta.mt)
) {
const mt = activeMeta.mt
const now = performance.now()
const prevShown = mt.countRevealed ? scrolledCount(mt, now) : 1
mt.count += add
if (!mt.countRevealed) {
mt.countRevealed = true
mt.rollFrom = 1
mt.countRevealAt = now
} else {
mt.rollFrom = prevShown
mt.countRevealAt = now
}
const elapsed = now - mt.birthTime
mt.duration = Math.max(mt.duration, elapsed + 1600)
spawnParticles(payload.giftType, now, 1)
emit('play-start', {
giftType: payload.giftType,
name: payload.name,
count: mt.count,
phase: 'mult',
})
ensureLoop()
return true
}
return false
}
function scheduleNextFromQueue(delayMs) {
clearTimeout(queueTimer)
queueTimer = setTimeout(() => {
@@ -570,6 +677,7 @@ function scheduleNextFromQueue(delayMs) {
function drainPlayQueue() {
if (queueTimer) return
if (!playQueue.length) {
activeMeta = null
stopIfIdle()
return
}
@@ -578,10 +686,11 @@ function drainPlayQueue() {
if (playQueue.length) scheduleNextFromQueue(wait)
}
/** 入队串行播放,保证同一时间只展示一个礼物特效 */
/** 入队串行播放;连续同礼物则合并为 ×N */
const play = (item) => {
const payload = normalizePlayItem(item)
if (!payload) return
if (tryBumpSame(payload)) return
playQueue.push(payload)
if (texts.length || particles.length || rings.length || flashAlpha > 0.01) {
if (!queueTimer) {
@@ -624,6 +733,20 @@ function animate(timestamp) {
ctx.clearRect(0, 0, W, H)
// 特效期间压暗背景(纯色半透明,不加模糊)
if (texts.length) {
const mt = texts[0]
const op = textOpacity(mt, now)
const dim = Math.min(0.4, 0.12 + op * 0.28)
if (dim > 0.02) {
ctx.save()
ctx.globalAlpha = dim
ctx.fillStyle = '#0c0c10'
ctx.fillRect(0, 0, W, H)
ctx.restore()
}
}
// rings
for (let i = rings.length - 1; i >= 0; i -= 1) {
const r = rings[i]
@@ -641,7 +764,7 @@ function animate(timestamp) {
ctx.strokeStyle = r.color
ctx.lineWidth = r.lineWidth * (1 - p * 0.5)
ctx.shadowColor = r.color
ctx.shadowBlur = 12
ctx.shadowBlur = 12 * SHADOW_MUL
ctx.beginPath()
ctx.ellipse(r.x, r.y, radius, radius * 0.72, 0, 0, Math.PI * 2)
ctx.stroke()
@@ -667,7 +790,7 @@ function animate(timestamp) {
ctx.globalAlpha = Math.max(0, opacity)
ctx.fillStyle = p.color
ctx.shadowColor = p.glow || p.color
ctx.shadowBlur = p.kind === 'spark' || p.kind === 'star' ? 10 : 6
ctx.shadowBlur = (p.kind === 'spark' || p.kind === 'star' ? 10 : 6) * SHADOW_MUL
if (p.kind === 'heart') {
ctx.translate(x, y)
@@ -705,6 +828,7 @@ function animate(timestamp) {
for (let i = texts.length - 1; i >= 0; i -= 1) {
const mt = texts[i]
if (textProgress(mt, now) >= 1) {
if (activeMeta && activeMeta.mt === mt) activeMeta = null
texts.splice(i, 1)
continue
}
@@ -717,19 +841,22 @@ function animate(timestamp) {
if (opacity < 0.01) continue
const wobble = Math.sin((now - mt.birthTime) / 180) * mt.wobble
// soft aura behind text
if (mt.aura) {
// soft aura behind textiOS 跳过径向渐变,避免卡顿/错乱)
if (mt.aura && !SKIP_AURA) {
const auraR = Math.max(0.1, Number(fontSize) * 1.8 * Math.max(0, scale) || 0)
if (auraR > 0.1) {
ctx.save()
ctx.globalAlpha = opacity * 0.22 * glowIntensity
const aura = ctx.createRadialGradient(cx, cy, 0, cx, cy, fontSize * 1.8 * scale)
const aura = ctx.createRadialGradient(cx, cy, 0, cx, cy, auraR)
aura.addColorStop(0, mt.glowColor)
aura.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = aura
ctx.beginPath()
ctx.arc(cx, cy, fontSize * 1.8 * scale, 0, Math.PI * 2)
ctx.arc(cx, cy, auraR, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
}
ctx.save()
ctx.globalAlpha = opacity
@@ -737,27 +864,40 @@ function animate(timestamp) {
ctx.rotate(wobble)
ctx.scale(scale, scale)
const glowBlur = fontSize * 0.4 * glowIntensity
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = glowBlur
ctx.strokeStyle = mt.strokeColor
ctx.lineWidth = fontSize * 0.065
ctx.lineJoin = 'round'
ctx.font = `700 ${fontSize}px "Ma Shan Zheng", "Great Vibes", cursive, serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.strokeText(mt.text, 0, 0)
const textGrad = ctx.createLinearGradient(0, -fontSize * 0.55, 0, fontSize * 0.55)
const g = mt.gradient
textGrad.addColorStop(0, g[0])
textGrad.addColorStop(0.35, g[1])
textGrad.addColorStop(0.7, g[2])
textGrad.addColorStop(1, g[3])
ctx.fillStyle = textGrad
ctx.shadowBlur = glowBlur * 1.5
ctx.fillText(mt.text, 0, 0)
ctx.shadowBlur = 0
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.fillText(mt.text, -fontSize * 0.018, -fontSize * 0.028)
const showMult = mt.countRevealed && mt.count > 1
const mainY = showMult ? -fontSize * 0.42 : 0
drawMilestoneLine(
ctx,
mt.baseText || mt.text,
fontSize,
glowBlur,
mt.gradient,
mt.strokeColor,
mt.glowColor,
mainY,
)
if (showMult) {
const n = scrolledCount(mt, now)
const multSize = fontSize * 0.72
const multY = fontSize * 0.55
// 滚动过程轻微上浮
const rollP = Math.min(1, (now - (mt.countRevealAt || now)) / COUNT_ROLL_MS)
const pop = 0.85 + 0.15 * (1 - (1 - rollP) ** 2)
ctx.save()
ctx.translate(0, multY)
ctx.scale(pop, pop)
drawMilestoneLine(
ctx,
`×${n}`,
multSize,
glowBlur * 0.85,
mt.gradient,
mt.strokeColor,
mt.glowColor,
0,
)
ctx.restore()
}
ctx.restore()
if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) {
@@ -824,6 +964,8 @@ const onResize = () => resizeCanvas()
onMounted(() => {
resizeCanvas()
window.addEventListener('resize', onResize)
window.visualViewport?.addEventListener('resize', onResize)
window.visualViewport?.addEventListener('scroll', onResize)
})
onUnmounted(() => {
@@ -831,9 +973,12 @@ onUnmounted(() => {
clearSecondaryTimers()
queueTimer = null
playQueue = []
activeMeta = null
running = false
if (rafId) cancelAnimationFrame(rafId)
window.removeEventListener('resize', onResize)
window.visualViewport?.removeEventListener('resize', onResize)
window.visualViewport?.removeEventListener('scroll', onResize)
texts = []
particles = []
rings = []
@@ -845,10 +990,17 @@ defineExpose({ play, playSequence })
<style scoped>
.gift-effect-canvas {
position: fixed;
inset: 0;
left: 0;
top: 0;
width: 100%;
height: 100%;
height: 100dvh;
z-index: 46;
pointer-events: none;
/* iOS独立合成层避免和左侧点赞 canvas / transform 滚动打架 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
</style>

View File

@@ -8,7 +8,13 @@
>
<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>
@@ -20,15 +26,13 @@ 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 formatLabel = (giftType, count = 1) => {
const base = GIFT_LABEL_MAP[giftType] || giftType
const n = Math.max(1, Number(count) || 1)
return n > 1 ? `${base}×${n}` : base
}
const baseLabel = (giftType) => GIFT_LABEL_MAP[giftType] || giftType
const removeItem = (id) => {
const t = timers.get(id)
@@ -36,37 +40,120 @@ const removeItem = (id) => {
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)
}
/** 追加一条(与当前特效同步);最新在底部,旧的上移;最多 5 条8 秒后向左淡出 */
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
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: Math.max(1, Number(count) || 1),
label: formatLabel(type, count),
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]
const timer = setTimeout(() => removeItem(id), LIFE_MS)
timers.set(id, timer)
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 })
defineExpose({ push, revealCount, bumpOrPush })
</script>
<style scoped>
@@ -77,6 +164,10 @@ defineExpose({ push })
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 {
@@ -88,12 +179,12 @@ defineExpose({ push })
.gift-feed-row {
display: inline-flex;
align-items: baseline;
align-items: center;
flex-wrap: wrap;
gap: 4px 6px;
max-width: 100%;
padding: 6px 10px;
border-radius: 999px;
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);
@@ -116,6 +207,13 @@ defineExpose({ push })
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;
@@ -123,6 +221,14 @@ defineExpose({ push })
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;
}

View File

@@ -4,12 +4,13 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { isIOS } from '@/composables/useAndroidOptimize'
const canvasRef = ref(null)
/** 同屏上限 */
const MAX_HEARTS = 55
const MAX_SPARKS = 120
const MAX_HEARTS = isIOS ? 36 : 55
const MAX_SPARKS = isIOS ? 80 : 120
/** 异常保护 */
const MAX_TOTAL = 5000
/** 每次「点赞效果」间隔 */
@@ -25,9 +26,9 @@ const RISE_SPEED_MIN = 70
const RISE_SPEED_MAX = 150
const DRIFT_AMP_MIN = 8
const DRIFT_AMP_MAX = 45
const GLOW_BLUR = 10
const GLOW_BLUR = isIOS ? 4 : 10
const GLOW_ALPHA = 0.55
const DPR_CAP = 2
const DPR_CAP = isIOS ? 1.5 : 2
/** 多色爱心:主色 */
const HEART_COLORS = [
@@ -466,7 +467,12 @@ defineExpose({ burst })
bottom: 0;
width: min(52vw, 320px);
height: 62vh;
height: 62dvh;
z-index: 45;
pointer-events: none;
transform: translateZ(0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
</style>

View File

@@ -1,13 +1,13 @@
/** 礼物类型常量表(与后端 giftTypeWhitelist 一致) */
export const GIFT_TYPES = [
{ key: '520', label: '520' },
{ key: '1314', label: '1314' },
{ key: 'forever', label: '长长久久' },
{ key: 'bald', label: '白头偕老' },
{ key: 'concentric', label: '永结同心' },
{ key: 'heaven', label: '天作之合' },
{ key: 'moisten', label: '相濡以沫' },
{ key: 'match', label: '佳偶天成' },
{ key: '520', label: '520', preview: 'linear-gradient(135deg, #ff8da6 0%, #ff2d55 55%, #c41e3a 100%)' },
{ key: '1314', label: '1314', preview: 'linear-gradient(135deg, #fff3b0 0%, #ffd700 50%, #e6a000 100%)' },
{ key: 'forever', label: '长长久久', preview: 'linear-gradient(135deg, #ffb3c1 0%, #c41e3a 55%, #6b0f1a 100%)' },
{ key: 'bald', label: '白头偕老', preview: 'linear-gradient(135deg, #ffffff 0%, #e8e4d9 45%, #b8a878 100%)' },
{ key: 'concentric', label: '永结同心', preview: 'linear-gradient(135deg, #f5dcc4 0%, #d4a574 50%, #b76e79 100%)' },
{ key: 'heaven', label: '天作之合', preview: 'linear-gradient(135deg, #cce4ff 0%, #5ba3ff 50%, #1a5bb5 100%)' },
{ key: 'moisten', label: '相濡以沫', preview: 'linear-gradient(135deg, #c5f5ef 0%, #40b4a6 50%, #165e5a 100%)' },
{ key: 'match', label: '佳偶天成', preview: 'linear-gradient(135deg, #ffd4a8 0%, #e85d75 50%, #b5475a 100%)' },
]
export const GIFT_LABEL_MAP = Object.fromEntries(GIFT_TYPES.map((g) => [g.key, g.label]))

View File

@@ -291,16 +291,16 @@
<!-- 礼物面板 -->
<div v-if="showGiftPanel" class="fixed inset-0 z-[60] bg-black/25 transition-opacity duration-200" @click="closeGiftPanel"></div>
<div
class="gift-panel fixed bottom-0 left-0 right-0 mx-auto w-full max-w-[420px] bg-[#fdfbf7] rounded-t-2xl shadow-xl transition-transform duration-300 z-[70] px-4 pt-4 pb-5 flex flex-col"
class="gift-panel fixed bottom-0 left-0 right-0 mx-auto w-full max-w-[420px] rounded-t-2xl shadow-xl transition-transform duration-300 z-[70] px-3 pt-3 pb-4 flex flex-col"
:class="{ 'pointer-events-none': !showGiftPanel }"
:style="{ transform: showGiftPanel ? 'translateY(0)' : 'translateY(110%)' }"
>
<div class="gift-panel-handle" aria-hidden="true"></div>
<div class="flex items-center justify-between mb-2.5 px-0.5">
<span class="text-[13px] text-[#a88c6b] tracking-[0.25em]">送一份心意</span>
<button type="button" class="p-1.5 text-[#a88c6b]/70 text-sm leading-none" @click="closeGiftPanel"></button>
<div class="flex items-center justify-between mb-1.5 px-0.5">
<span class="text-[12px] text-[#c8c4bc] tracking-[0.25em]">送一份心意</span>
<button type="button" class="p-1 text-[#c8c4bc]/80 text-sm leading-none" @click="closeGiftPanel"></button>
</div>
<div class="gift-quota mb-2.5">
<div class="gift-quota mb-1.5">
<span v-if="giftQuota.remain > 0">还可赠送 <b>{{ giftQuota.remain }}</b> </span>
<span v-else>赠送次数已用完再点赞 <b>{{ giftQuota.likes_to_next }}</b> 次可多送 1 </span>
<span class="gift-quota-sub">已送 {{ giftQuota.gift_used }} / {{ giftQuota.gift_allow }} · 点赞 {{ giftQuota.my_likes }} {{ giftQuota.likes_per_gift }} +1 </span>
@@ -308,7 +308,7 @@
<input
v-model="giftForm.name"
placeholder="您的姓名"
class="w-full bg-white border-[0.5px] border-[#a88c6b]/25 rounded-lg px-3 py-2 text-[12px] mb-2.5 focus:outline-none focus:border-[#a88c6b]"
class="gift-name-input w-full rounded-lg px-3 py-2 text-[12px] mb-1.5 focus:outline-none"
/>
<div class="gift-grid">
<button
@@ -319,8 +319,11 @@
:disabled="giftBusy || giftQuota.remain <= 0"
@click="handleSendGift(g.key)"
>
<span class="gift-item-label calligraphy-strong">{{ g.label }}</span>
<span class="gift-item-count">{{ giftCounts[g.key] || 0 }}</span>
<span
class="gift-item-preview calligraphy-strong"
:style="{ backgroundImage: g.preview }"
>{{ g.label }}</span>
<span class="gift-item-caption">{{ g.label }}</span>
</button>
</div>
</div>
@@ -1538,7 +1541,11 @@ const openGiftPanel = async () => {
} catch { /* ignore */ }
}
const onGiftPlayStart = ({ giftType, name, count }) => {
const onGiftPlayStart = ({ giftType, name, count, phase }) => {
if (phase === 'mult') {
giftFeedRef.value?.revealCount(giftType, count)
return
}
giftFeedRef.value?.push(name, giftType, count)
}
@@ -1552,7 +1559,14 @@ const replayGiftFeedOnly = (list) => {
const it = items[i]
i += 1
giftFeedRef.value?.push(it.name, it.giftType, it.count)
if (i < items.length) setTimeout(tick, 900)
if (it.count > 1) {
setTimeout(() => {
giftFeedRef.value?.revealCount(it.giftType, it.count)
if (i < items.length) setTimeout(tick, 500)
}, 900)
} else if (i < items.length) {
setTimeout(tick, 900)
}
}
tick()
}
@@ -1583,7 +1597,7 @@ const handleSendGift = async (giftType) => {
if (showGiftFx.value) {
giftEffectRef.value?.play({ giftType, name, count: 1 })
} else {
giftFeedRef.value?.push(name, giftType, 1)
giftFeedRef.value?.bumpOrPush(name, giftType)
}
toast.success('心意已送达')
} catch (e) {
@@ -1901,73 +1915,100 @@ watch(
.gift-fab.busy { opacity: 0.7; pointer-events: none; }
.gift-icon { font-size: 16px; color: #a88c6b; line-height: 1; }
.gift-panel {
max-height: min(52dvh, 380px);
max-height: min(56dvh, 420px);
overflow-y: auto;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(55, 55, 60, 0.94);
color: #e8e6e2;
}
.gift-panel-handle {
width: 36px;
height: 4px;
width: 32px;
height: 3px;
border-radius: 999px;
background: rgba(168, 140, 107, 0.28);
margin: 0 auto 10px;
background: rgba(255, 255, 255, 0.22);
margin: 0 auto 8px;
}
.gift-quota {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(168, 140, 107, 0.08);
color: #7a6550;
font-size: 11px;
line-height: 1.45;
gap: 1px;
padding: 6px 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: rgba(232, 230, 226, 0.78);
font-size: 10px;
line-height: 1.4;
letter-spacing: 0.04em;
}
.gift-quota b { color: #a88c6b; font-weight: 600; }
.gift-quota b { color: #f0e6c8; font-weight: 600; }
.gift-quota-sub {
font-size: 10px;
color: #8A8680;
font-size: 9px;
color: rgba(232, 230, 226, 0.48);
letter-spacing: 0.02em;
}
.gift-name-input {
background: rgba(255, 255, 255, 0.08);
border: 0.5px solid rgba(255, 255, 255, 0.14);
color: #f5f3ef;
}
.gift-name-input::placeholder {
color: rgba(232, 230, 226, 0.4);
}
.gift-name-input:focus {
border-color: rgba(240, 230, 200, 0.45);
}
.gift-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
gap: 4px;
}
.gift-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 52px;
padding: 6px 4px;
justify-content: flex-start;
gap: 1px;
min-height: 72px;
padding: 6px 2px 4px;
border-radius: 10px;
border: 0.5px solid rgba(168, 140, 107, 0.22);
background: #fff;
color: #5c4a35;
border: 0.5px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: #e8e6e2;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
}
.gift-item:active { transform: scale(0.96); }
.gift-item:disabled { opacity: 0.6; pointer-events: none; }
.gift-item-label {
font-size: 14px;
line-height: 1.15;
color: #a88c6b;
.gift-item:disabled { opacity: 0.45; pointer-events: none; }
.gift-item-preview {
display: block;
width: 100%;
min-height: 40px;
line-height: 1.1;
font-size: 24px;
letter-spacing: 0.04em;
text-align: center;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
}
.gift-item-count {
font-size: 10px;
color: #8A8680;
letter-spacing: 0.04em;
.gift-item-caption {
font-size: 8px;
line-height: 1.15;
color: rgba(232, 230, 226, 0.5);
letter-spacing: 0.03em;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 360px) {
.gift-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.gift-item { min-height: 48px; }
.gift-item-label { font-size: 15px; }
.gift-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
.gift-item { min-height: 80px; }
.gift-item-preview { font-size: 28px; min-height: 46px; }
.gift-item-caption { font-size: 9px; }
}
.action-comment-row {
position: relative;