1. 礼物特效

This commit is contained in:
李琦
2026-08-03 12:20:55 +08:00
parent 63fea7e903
commit 170102b0d8
12 changed files with 813 additions and 216 deletions

View File

@@ -76,22 +76,6 @@
</div>
</div>
<div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">礼物统计</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">各礼物收到次数 · 合计 {{ giftStats.total }}</div>
</div>
<a-button size="small" :loading="giftStatsLoading" @click="loadGiftStats">
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
</a-button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<div v-for="g in giftTypeList" :key="g.key" class="stat-card">
<div class="stat-label calligraphy">{{ g.label }}</div>
<div class="stat-value">{{ giftStats.counts[g.key] || 0 }}</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-5">
<div class="chart-card">
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">地区分布</div>
@@ -139,6 +123,54 @@
</div>
</a-tab-pane>
<!-- 礼物 -->
<a-tab-pane key="gifts" tab="礼物">
<div v-if="activeTab === 'gifts'" class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">礼物统计</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">各礼物收到次数 · 合计 {{ giftStats.total }}</div>
</div>
<a-button size="small" :loading="giftStatsLoading" @click="loadGiftStats">
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
</a-button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div v-for="g in giftTypeList" :key="g.key" class="stat-card">
<div class="stat-label calligraphy">{{ g.label }}</div>
<div class="stat-value">{{ giftStats.counts[g.key] || 0 }}</div>
<div class="text-[10px] text-[#8A8680] mt-1">兑换 {{ giftCostsEdit[g.key] || g.cost }} </div>
</div>
</div>
<div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">礼物兑换配置</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">每个礼物消耗的点赞次数 · 默认阶梯 50 / 100 / 200</div>
</div>
<div class="flex gap-2">
<a-button size="small" @click="resetGiftCostsEdit">恢复默认</a-button>
<a-button size="small" type="primary" class="!bg-[#a88c6b]" :loading="giftCostsSaving" @click="saveGiftCostsEdit">
保存配置
</a-button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div v-for="g in giftTypeList" :key="'cost-' + g.key" class="stat-card !py-3">
<div class="stat-label calligraphy mb-2">{{ g.label }}</div>
<a-input-number
v-model:value="giftCostsEdit[g.key]"
:min="1"
:max="10000"
:step="10"
class="w-full"
addon-after=""
/>
</div>
</div>
</div>
</a-tab-pane>
<!-- 访客记录 -->
<a-tab-pane key="visits" tab="访客记录">
<div v-if="activeTab === 'visits'" class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
@@ -983,13 +1015,13 @@ import { CanvasRenderer } from 'echarts/renderers'
import {
getConfigSection, saveConfigSection, uploadImage, getRsvpList, getUploadConfig, saveUploadConfig,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitList,
getPosterConfig, savePosterConfig, getGiftStats,
getPosterConfig, savePosterConfig, getGiftStats, saveGiftCosts,
} from '@/api/wedding'
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { GIFT_TYPES } from '@/utils/giftTypes'
import { GIFT_TYPES, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
@@ -1003,7 +1035,7 @@ const adminStore = useAdminStore()
const TAB_KEY = 'wedding_admin_active_tab'
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
const VALID_TABS = ['overview', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku']
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku']
const SECTION_KEYS = {
basic: ['groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'endImg'],
@@ -1044,11 +1076,42 @@ const danmakuFilter = ref('pending')
const visitStatsLoading = ref(false)
const visitListLoading = ref(false)
const giftStatsLoading = ref(false)
const giftCostsSaving = ref(false)
const giftTypeList = GIFT_TYPES
const giftStats = reactive({
total: 0,
counts: Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0])),
})
const giftCostsEdit = reactive(emptyGiftCosts())
function applyGiftCostsFromServer(costs) {
if (!costs || typeof costs !== 'object') return
for (const g of GIFT_TYPES) {
const v = Number(costs[g.key])
if (Number.isFinite(v) && v > 0) giftCostsEdit[g.key] = v
}
}
function resetGiftCostsEdit() {
Object.assign(giftCostsEdit, emptyGiftCosts())
}
async function saveGiftCostsEdit() {
giftCostsSaving.value = true
try {
const payload = {}
for (const g of GIFT_TYPES) {
payload[g.key] = Number(giftCostsEdit[g.key]) || DEFAULT_GIFT_COSTS[g.key]
}
const res = await saveGiftCosts(payload)
applyGiftCostsFromServer(res.data?.costs || payload)
message.success('礼物兑换配置已保存')
} catch (e) {
message.error(e?.message || '保存失败')
} finally {
giftCostsSaving.value = false
}
}
const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [], devices: [] })
const visitList = ref([])
const visitPreviewList = ref([])
@@ -1728,6 +1791,7 @@ async function loadGiftStats() {
giftStats.counts[g.key] = Number(counts[g.key]) || 0
}
giftStats.total = Number(d.total) || Object.values(giftStats.counts).reduce((a, b) => a + b, 0)
applyGiftCostsFromServer(d.costs)
} catch (e) {
message.error(e?.message || '加载礼物统计失败')
} finally {
@@ -1787,7 +1851,7 @@ async function loadVisitList() {
}
async function loadOverview() {
await Promise.all([loadVisitStats(), loadVisitPreview(), loadGiftStats()])
await Promise.all([loadVisitStats(), loadVisitPreview()])
}
function onVisitTableChange(pag) {
@@ -1803,6 +1867,10 @@ async function onTabActivate(tab) {
await loadOverview()
return
}
if (tab === 'gifts') {
await loadGiftStats()
return
}
if (tab === 'visits') {
await loadVisitList()
return

View File

@@ -55,8 +55,8 @@
</div>
<!-- 右上角自动滚动 / 弹幕 / 礼物特效 -->
<div class="fixed top-6 right-5 z-50 flex flex-col items-end gap-3">
<div @click="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300"
<div class="fixed top-6 right-5 z-[80] flex flex-col items-end gap-3 pointer-events-auto">
<div @click.stop="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300"
:class="isAutoScroll ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30'"
title="自动滚动">
<img v-if="isAutoScroll" src="https://api.iconify.design/lucide:pause.svg?color=%23a88c6b" class="w-4 h-4" />
@@ -66,19 +66,19 @@
type="button"
class="control-btn cursor-pointer transition-all duration-300"
:class="showDanmaku ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30 opacity-55'"
title="弹幕展示"
@click="toggleShowDanmaku"
:title="showDanmaku ? '关闭弹幕' : '开启弹幕'"
@click.stop="toggleShowDanmaku"
>
<i class="fa-solid fa-comment-dots text-[13px]"></i>
<i class="fa-solid fa-comment-dots text-[13px]" :class="showDanmaku ? 'text-[#a88c6b]' : 'text-[#a88c6b]/70'"></i>
</button>
<button
type="button"
class="control-btn cursor-pointer transition-all duration-300"
:class="showGiftFx ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30 opacity-55'"
title="礼物特效"
@click="toggleShowGiftFx"
:title="showGiftFx ? '关闭礼物特效' : '开启礼物特效'"
@click.stop="toggleShowGiftFx"
>
<i class="fa-solid fa-gift text-[13px]"></i>
<i class="fa-solid fa-gift text-[13px]" :class="showGiftFx ? 'text-[#a88c6b]' : 'text-[#a88c6b]/70'"></i>
</button>
</div>
@@ -95,13 +95,39 @@
<!-- 行1点赞 -->
<div class="action-dock-row">
<div class="like-hit-zone">
<div
v-if="showLikeBubbles"
class="like-bubbles"
@click.stop
@touchstart.stop
>
<button
v-for="(n, i) in likeQuickAmounts"
:key="n"
type="button"
class="action-pill like-fab like-bubble-btn"
:style="likeBubbleStyle(i)"
:disabled="likeBusy"
@click.stop="handleQuickLike(n)"
>
<i class="fa-solid fa-heart like-icon"></i>
<span class="like-count">+{{ n }}</span>
</button>
</div>
<button
type="button"
class="action-pill like-fab"
:class="{ liked: likePulse, busy: likeBusy }"
:disabled="likeBusy"
title="点赞"
@click="handleLike"
title="点赞(长按快捷次数)"
@click="handleLikeClick"
@touchstart.passive="onLikePressStart"
@touchend="onLikePressEnd"
@touchcancel="onLikePressCancel"
@mousedown="onLikePressStart"
@mouseup="onLikePressEnd"
@mouseleave="onLikePressCancel"
@contextmenu.prevent
>
<i class="fa-heart like-icon" :class="likePulse ? 'fa-solid' : 'fa-regular'"></i>
<span class="like-count">{{ likeCount }}</span>
@@ -301,30 +327,48 @@
<button type="button" class="p-1 text-[#c8c4bc]/80 text-sm leading-none" @click="closeGiftPanel"></button>
</div>
<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>
<span>可用点赞 <b>{{ giftQuota.available }}</b></span>
<span class="gift-quota-sub">总赞 {{ giftQuota.my_likes }} · 已兑 {{ giftQuota.spent }} · 已送 {{ giftQuota.gift_used }} </span>
</div>
<input
v-model="giftForm.name"
placeholder="您的姓名"
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
v-for="g in giftTypes"
:key="g.key"
type="button"
class="gift-item"
:disabled="giftBusy || giftQuota.remain <= 0"
@click="handleSendGift(g.key)"
<div
ref="giftPagesRef"
class="gift-pages"
@scroll.passive="onGiftPagesScroll"
>
<div
v-for="(page, pi) in giftPages"
:key="pi"
class="gift-page"
>
<span
class="gift-item-preview calligraphy-strong"
:style="{ backgroundImage: g.preview }"
>{{ g.label }}</span>
<span class="gift-item-caption">{{ g.label }}</span>
</button>
<button
v-for="g in page"
:key="g.key"
type="button"
class="gift-item"
:disabled="giftBusy || giftQuota.available < giftCostOf(g.key)"
@click="handleSendGift(g.key)"
>
<span
class="gift-item-preview calligraphy-strong"
:style="{ backgroundImage: g.preview }"
>{{ g.label }}</span>
<span class="gift-item-caption">{{ g.label }}</span>
<span class="gift-item-cost">{{ giftCostOf(g.key) }} </span>
</button>
</div>
</div>
<div class="gift-page-dots" aria-hidden="true">
<span
v-for="(_, pi) in giftPages"
:key="pi"
class="gift-page-dot"
:class="{ active: giftPageIndex === pi }"
/>
</div>
</div>
@@ -702,7 +746,14 @@ import { toast } from '@/composables/useToast'
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
import { getClientId } from '@/utils/clientId'
import { getGuestName, setGuestName } from '@/utils/guestName'
import { GIFT_TYPES, emptyGiftCounts, aggregateGiftRecent } from '@/utils/giftTypes'
import {
GIFT_TYPES,
emptyGiftCounts,
emptyGiftCosts,
aggregateGiftRecent,
chunkGiftTypes,
LIKE_QUICK_AMOUNTS,
} from '@/utils/giftTypes'
import { sendVisitBeacon } from '@/utils/visitBeacon'
import { optimizeImageUrl, isIOS } from '@/composables/useAndroidOptimize'
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
@@ -826,30 +877,119 @@ const likeCount = ref(0)
const likePulse = ref(false)
const likeBusy = ref(false)
let likePulseTimer = null
const giftTypes = GIFT_TYPES
const giftPages = chunkGiftTypes(GIFT_TYPES, 4)
const giftPagesRef = ref(null)
const giftPageIndex = ref(0)
const giftCounts = reactive(emptyGiftCounts())
const giftCosts = reactive(emptyGiftCosts())
const giftTotal = ref(0)
const giftBusy = ref(false)
const showGiftPanel = ref(false)
const giftForm = reactive({ name: '' })
const giftQuota = reactive({
my_likes: 0,
spent: 0,
available: 0,
gift_used: 0,
gift_allow: 1,
gift_remain: 1,
likes_to_next: 50,
likes_per_gift: 50,
})
const pendingGiftReplay = ref([])
const likeQuickAmounts = LIKE_QUICK_AMOUNTS
const showLikeBubbles = ref(false)
let likePressTimer = null
let likePressFired = false
let likeSkipClick = false
let likeBubblesHideTimer = null
/** 气泡绕点赞左侧半圆弧分布(上 → 左 → 下) */
const likeBubbleStyle = (index) => {
const total = likeQuickAmounts.length || 1
const startDeg = 132
const endDeg = 228
const deg = total === 1 ? 180 : startDeg + ((endDeg - startDeg) * index) / (total - 1)
const rad = (deg * Math.PI) / 180
const r = 78
const x = Math.cos(rad) * r
const y = -Math.sin(rad) * r
return {
'--bx': `${x.toFixed(1)}px`,
'--by': `${y.toFixed(1)}px`,
'--delay': `${0.04 + index * 0.05}s`,
}
}
const giftCostOf = (key) => Number(giftCosts[key]) || Number(GIFT_TYPES.find((g) => g.key === key)?.cost) || 50
const applyGiftQuota = (quota) => {
if (!quota || typeof quota !== 'object') return
giftQuota.my_likes = Number(quota.my_likes) || 0
giftQuota.spent = Number(quota.spent) || 0
giftQuota.available = Math.max(0, Number(quota.available) || 0)
giftQuota.gift_used = Number(quota.gift_used) || 0
giftQuota.gift_allow = Number(quota.gift_allow) || 1
giftQuota.gift_remain = Math.max(0, Number(quota.gift_remain) || 0)
giftQuota.likes_to_next = Number(quota.likes_to_next) || 50
giftQuota.likes_per_gift = Number(quota.likes_per_gift) || 50
if (quota.costs && typeof quota.costs === 'object') {
for (const g of GIFT_TYPES) {
const v = Number(quota.costs[g.key])
if (Number.isFinite(v) && v > 0) giftCosts[g.key] = v
}
}
}
const onGiftPagesScroll = () => {
const el = giftPagesRef.value
if (!el) return
const w = el.clientWidth || 1
giftPageIndex.value = Math.round(el.scrollLeft / w)
}
const hideLikeBubblesSoon = () => {
clearTimeout(likeBubblesHideTimer)
likeBubblesHideTimer = setTimeout(() => {
showLikeBubbles.value = false
}, 3500)
}
const clearLikePressTimer = () => {
if (likePressTimer) {
clearTimeout(likePressTimer)
likePressTimer = null
}
}
const onLikePressStart = (e) => {
if (likeBusy.value) return
if (e.type === 'mousedown' && e.button !== 0) return
likePressFired = false
clearLikePressTimer()
likePressTimer = setTimeout(() => {
likePressFired = true
likeSkipClick = true
showLikeBubbles.value = true
hideLikeBubblesSoon()
if (typeof navigator !== 'undefined' && navigator.vibrate) {
try { navigator.vibrate(12) } catch { /* ignore */ }
}
}, 420)
}
const onLikePressEnd = () => {
clearLikePressTimer()
}
const onLikePressCancel = () => {
clearLikePressTimer()
}
const handleLikeClick = () => {
if (likeSkipClick || likePressFired) {
likeSkipClick = false
likePressFired = false
return
}
handleLike(1)
}
const handleQuickLike = (n) => {
showLikeBubbles.value = false
handleLike(n)
}
const DANMAKU_PREF_KEY = 'wedding_show_danmaku_v1'
@@ -869,9 +1009,21 @@ const toggleShowDanmaku = () => {
showDanmaku.value = !showDanmaku.value
try { localStorage.setItem(DANMAKU_PREF_KEY, showDanmaku.value ? '1' : '0') } catch { /* ignore */ }
}
const toggleShowGiftFx = () => {
showGiftFx.value = !showGiftFx.value
try { localStorage.setItem(GIFT_FX_PREF_KEY, showGiftFx.value ? '1' : '0') } catch { /* ignore */ }
const toggleShowGiftFx = async () => {
const next = !showGiftFx.value
if (!next) {
// 先清特效/列表,再切开关,避免同帧 Transition 冲突
giftEffectRef.value?.clear?.()
giftFeedRef.value?.clear?.()
showGiftFx.value = false
try { localStorage.setItem(GIFT_FX_PREF_KEY, '0') } catch { /* ignore */ }
return
}
showGiftFx.value = true
try { localStorage.setItem(GIFT_FX_PREF_KEY, '1') } catch { /* ignore */ }
// 重新打开时拉取最近礼物并回放
await nextTick()
await loadGiftStatus()
}
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
@@ -1384,15 +1536,13 @@ const completeIntro = () => {
if (pendingLikeBurst > 0) {
const n = pendingLikeBurst
pendingLikeBurst = 0
nextTick(() => likeBurstRef.value?.burst(n))
nextTick(() => likeBurstRef.value?.burst(n, { durationMs: 2800, maxVisual: 10 }))
}
if (pendingGiftReplay.value.length) {
const list = pendingGiftReplay.value.slice()
pendingGiftReplay.value = []
if (showGiftFx.value) {
nextTick(() => giftEffectRef.value?.playSequence(list, { max: 12 }))
} else {
nextTick(() => replayGiftFeedOnly(list))
}
}
scheduleAutoScrollStart()
@@ -1488,7 +1638,7 @@ const handleSubmitRsvp = async () => {
if (res.code === 200) {
setGuestName(rsvpForm.name)
syncGuestNameIntoForms()
if (res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
if (showDanmaku.value && res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
isSubmitted.value = true
}
else toast.error(res.error || '提交失败')
@@ -1507,7 +1657,7 @@ const handleSubmitBlessing = async () => {
if (res.code === 200) {
setGuestName(blessingForm.name)
syncGuestNameIntoForms()
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
if (showDanmaku.value && res.data) danmakuLayerRef.value?.pushItem?.(res.data)
blessingSubmitted.value = true
scheduleBlessingAutoClose()
}
@@ -1531,9 +1681,18 @@ const applyGiftCounts = (counts, total) => {
}
const openGiftPanel = async () => {
showLikeBubbles.value = false
syncGuestNameIntoForms()
giftForm.name = getGuestName() || giftForm.name
showGiftPanel.value = true
await nextTick()
const el = giftPagesRef.value
if (el) {
const maxPage = Math.max(0, giftPages.length - 1)
const page = Math.min(Math.max(0, giftPageIndex.value), maxPage)
giftPageIndex.value = page
el.scrollLeft = page * (el.clientWidth || 0)
}
try {
const res = await getGiftStatus(getClientId())
applyGiftCounts(res.data?.counts, res.data?.total)
@@ -1542,6 +1701,7 @@ const openGiftPanel = async () => {
}
const onGiftPlayStart = ({ giftType, name, count, phase }) => {
if (!showGiftFx.value) return
if (phase === 'mult') {
giftFeedRef.value?.revealCount(giftType, count)
return
@@ -1549,36 +1709,16 @@ const onGiftPlayStart = ({ giftType, name, count, phase }) => {
giftFeedRef.value?.push(name, giftType, count)
}
/** 关闭特效时仍按序刷左下角记录 */
const replayGiftFeedOnly = (list) => {
const items = Array.isArray(list) ? list : []
if (!items.length) return
let i = 0
const tick = () => {
if (i >= items.length) return
const it = items[i]
i += 1
giftFeedRef.value?.push(it.name, it.giftType, it.count)
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()
}
const closeGiftPanel = () => {
showGiftPanel.value = false
}
const handleSendGift = async (giftType) => {
if (giftBusy.value) return
if (giftQuota.remain <= 0) {
return toast.error(`赠送次数已用完,再点赞 ${giftQuota.likes_to_next} 次可多送 1 次`)
const cost = giftCostOf(giftType)
if (giftQuota.available < cost) {
const need = cost - giftQuota.available
return toast.error(`点赞不足,再点赞 ${need} 次可兑换`)
}
const name = giftForm.name.trim()
if (!name) return toast.error('请填写姓名')
@@ -1596,8 +1736,6 @@ const handleSendGift = async (giftType) => {
closeGiftPanel()
if (showGiftFx.value) {
giftEffectRef.value?.play({ giftType, name, count: 1 })
} else {
giftFeedRef.value?.bumpOrPush(name, giftType)
}
toast.success('心意已送达')
} catch (e) {
@@ -1619,7 +1757,7 @@ const loadLikeStatus = async () => {
if (count <= 0) return
// 揭幕中则等结束后再播,避免帘幕后白播
if (showIntro.value) pendingLikeBurst = count
else nextTick(() => likeBurstRef.value?.burst(count))
else nextTick(() => likeBurstRef.value?.burst(count, { durationMs: 2800, maxVisual: 10 }))
} catch { /* ignore */ }
}
@@ -1633,26 +1771,25 @@ const loadGiftStatus = async () => {
if (!aggregated.length) return
if (showIntro.value) pendingGiftReplay.value = aggregated
else if (showGiftFx.value) nextTick(() => giftEffectRef.value?.playSequence(aggregated, { max: 12 }))
else nextTick(() => replayGiftFeedOnly(aggregated))
} catch { /* ignore */ }
}
const handleLike = async () => {
const handleLike = async (count = 1) => {
if (likeBusy.value) return
const n = Math.max(1, Math.min(200, Number(count) || 1))
likeBusy.value = true
try {
const res = await submitLike(getClientId())
likeCount.value = Number(res.data?.count) || likeCount.value + 1
const res = await submitLike(getClientId(), n)
likeCount.value = Number(res.data?.count) || likeCount.value + n
likePulse.value = true
clearTimeout(likePulseTimer)
likePulseTimer = setTimeout(() => { likePulse.value = false }, 600)
likeBurstRef.value?.burst(1)
// 点赞后刷新个人礼物额度(抽屉打开时立刻可见)
giftQuota.my_likes += 1
giftQuota.gift_allow = 1 + Math.floor(giftQuota.my_likes / giftQuota.likes_per_gift)
giftQuota.gift_remain = Math.max(0, giftQuota.gift_allow - giftQuota.gift_used)
const mod = giftQuota.my_likes % giftQuota.likes_per_gift
giftQuota.likes_to_next = mod === 0 ? giftQuota.likes_per_gift : giftQuota.likes_per_gift - mod
likeBurstRef.value?.burst(n, n > 1 ? { durationMs: 2400, maxVisual: 9 } : undefined)
if (res.data?.quota) applyGiftQuota(res.data.quota)
else {
giftQuota.my_likes += n
giftQuota.available = Math.max(0, giftQuota.my_likes - giftQuota.spent)
}
} catch (e) {
toast.error(e.message || '点赞失败')
} finally {
@@ -1759,6 +1896,9 @@ onUnmounted(() => {
clearTimeout(pauseTimer)
clearTimeout(introTimer)
clearTimeout(scrollStartTimer)
clearTimeout(likePressTimer)
clearTimeout(likeBubblesHideTimer)
clearTimeout(likePulseTimer)
clearBottomReturn()
clearBlessingAutoClose()
})
@@ -1862,6 +2002,47 @@ watch(
align-items: center;
justify-content: center;
z-index: 3;
overflow: visible;
}
.like-bubbles {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
z-index: 8;
pointer-events: none;
}
.like-bubble-btn {
position: absolute;
left: 0;
top: 0;
pointer-events: auto;
margin: 0;
min-width: 52px;
border-color: rgba(244, 114, 182, 0.4);
background: rgba(255, 241, 247, 0.72);
color: #f472b6;
box-shadow: 0 6px 16px rgba(92, 74, 53, 0.12);
animation: like-bubble-in 0.28s cubic-bezier(0.22, 1.2, 0.36, 1) both;
animation-delay: var(--delay, 0s);
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px)));
}
.like-bubble-btn .like-icon { color: #f472b6; }
.like-bubble-btn .like-count { color: #e11d74; font-weight: 600; }
.like-bubble-btn:active {
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px))) scale(0.94);
}
.like-bubble-btn:disabled { opacity: 0.55; }
@keyframes like-bubble-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.55);
}
to {
opacity: 1;
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px))) scale(1);
}
}
.action-pill {
min-width: 52px;
@@ -1915,8 +2096,9 @@ 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(56dvh, 420px);
overflow-y: auto;
height: calc(268px + env(safe-area-inset-bottom, 0px));
max-height: calc(268px + env(safe-area-inset-bottom, 0px));
overflow: hidden;
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(55, 55, 60, 0.94);
color: #e8e6e2;
@@ -1927,6 +2109,7 @@ watch(
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
margin: 0 auto 8px;
flex-shrink: 0;
}
.gift-quota {
display: flex;
@@ -1939,6 +2122,7 @@ watch(
font-size: 10px;
line-height: 1.4;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.gift-quota b { color: #f0e6c8; font-weight: 600; }
.gift-quota-sub {
@@ -1950,6 +2134,7 @@ watch(
background: rgba(255, 255, 255, 0.08);
border: 0.5px solid rgba(255, 255, 255, 0.14);
color: #f5f3ef;
flex-shrink: 0;
}
.gift-name-input::placeholder {
color: rgba(232, 230, 226, 0.4);
@@ -1957,10 +2142,47 @@ watch(
.gift-name-input:focus {
border-color: rgba(240, 230, 200, 0.45);
}
.gift-grid {
.gift-pages {
display: flex;
flex: 1;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.gift-pages::-webkit-scrollbar { display: none; }
.gift-page {
flex: 0 0 100%;
width: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
align-content: start;
box-sizing: border-box;
padding-right: 1px;
}
.gift-page-dots {
display: flex;
justify-content: center;
align-items: center;
gap: 5px;
padding-top: 8px;
flex-shrink: 0;
}
.gift-page-dot {
width: 5px;
height: 5px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
transition: background 0.2s ease, transform 0.2s ease;
}
.gift-page-dot.active {
background: rgba(240, 230, 200, 0.85);
transform: scale(1.15);
}
.gift-item {
display: flex;
@@ -1968,7 +2190,7 @@ watch(
align-items: center;
justify-content: flex-start;
gap: 1px;
min-height: 72px;
min-height: 88px;
padding: 6px 2px 4px;
border-radius: 10px;
border: 0.5px solid rgba(255, 255, 255, 0.1);
@@ -1976,17 +2198,20 @@ watch(
color: #e8e6e2;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
overflow: hidden;
}
.gift-item:active { transform: scale(0.96); }
.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;
min-height: 36px;
line-height: 1;
font-size: clamp(15px, 4.6vw, 21px);
letter-spacing: 0.02em;
text-align: center;
white-space: nowrap;
overflow: hidden;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
@@ -2004,11 +2229,13 @@ watch(
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 360px) {
.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; }
.gift-item-cost {
margin-top: 1px;
font-size: 9px;
line-height: 1;
color: rgba(240, 230, 200, 0.72);
letter-spacing: 0.02em;
white-space: nowrap;
}
.action-comment-row {
position: relative;