1. 礼物特效

This commit is contained in:
李琦
2026-08-03 11:24:55 +08:00
parent 7831e0d11a
commit ab2cafeb1b
12 changed files with 1686 additions and 35 deletions

View File

@@ -76,6 +76,22 @@
</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>
@@ -967,12 +983,13 @@ import { CanvasRenderer } from 'echarts/renderers'
import {
getConfigSection, saveConfigSection, uploadImage, getRsvpList, getUploadConfig, saveUploadConfig,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitList,
getPosterConfig, savePosterConfig,
getPosterConfig, savePosterConfig, getGiftStats,
} 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 { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
@@ -1026,6 +1043,12 @@ const danmakuFilter = ref('pending')
const visitStatsLoading = ref(false)
const visitListLoading = ref(false)
const giftStatsLoading = ref(false)
const giftTypeList = GIFT_TYPES
const giftStats = reactive({
total: 0,
counts: Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0])),
})
const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [], devices: [] })
const visitList = ref([])
const visitPreviewList = ref([])
@@ -1695,6 +1718,23 @@ function renderDeviceChart() {
)
}
async function loadGiftStats() {
giftStatsLoading.value = true
try {
const res = await getGiftStats()
const d = res.data || {}
const counts = d.counts || {}
for (const g of GIFT_TYPES) {
giftStats.counts[g.key] = Number(counts[g.key]) || 0
}
giftStats.total = Number(d.total) || Object.values(giftStats.counts).reduce((a, b) => a + b, 0)
} catch (e) {
message.error(e?.message || '加载礼物统计失败')
} finally {
giftStatsLoading.value = false
}
}
async function loadVisitStats() {
visitStatsLoading.value = true
try {
@@ -1747,7 +1787,7 @@ async function loadVisitList() {
}
async function loadOverview() {
await Promise.all([loadVisitStats(), loadVisitPreview()])
await Promise.all([loadVisitStats(), loadVisitPreview(), loadGiftStats()])
}
function onVisitTableChange(pag) {

View File

@@ -51,7 +51,7 @@
:active="stage >= 2"
animate-by="letters"
:delay="40"
class-name="hb-name"
class-name="hb-name calligraphy"
@animation-complete="namesRightReady = true"
/>
<span class="hb-xi-circle"></span>
@@ -61,7 +61,7 @@
:active="namesRightReady"
animate-by="letters"
:delay="40"
class-name="hb-name"
class-name="hb-name calligraphy"
@animation-complete="bumpStage(3)"
/>
</div>
@@ -92,7 +92,7 @@
cursor-class-name="hb-cursor"
:typing-speed="48"
:initial-delay="80"
class-name="hb-line"
class-name="hb-line calligraphy"
@animation-complete="onLineDone(i)"
/>
<BlurText
@@ -101,7 +101,7 @@
:active="lineStage > i"
animate-by="words"
:delay="90"
class-name="hb-line"
class-name="hb-line calligraphy"
@animation-complete="onLineDone(i)"
/>
</template>
@@ -114,7 +114,7 @@
:active="stage >= 5"
animate-by="letters"
:delay="35"
class-name="hb-footer-text"
class-name="hb-footer-text calligraphy"
@animation-complete="bumpStage(6)"
/>
</div>

View File

@@ -24,8 +24,8 @@
@animation-complete="bumpStage(3)"
/>
<div v-if="stage >= 3" class="sp-subs">
<BlurText :text="cfg.subSlogan1 || ''" :active="true" animate-by="letters" :delay="50" class-name="sp-sub" />
<BlurText :text="cfg.subSlogan2 || ''" :active="true" animate-by="letters" :delay="50" class-name="sp-sub" />
<BlurText :text="cfg.subSlogan1 || ''" :active="true" animate-by="letters" :delay="50" class-name="sp-sub calligraphy" />
<BlurText :text="cfg.subSlogan2 || ''" :active="true" animate-by="letters" :delay="50" class-name="sp-sub calligraphy" />
</div>
</div>
</section>
@@ -42,7 +42,7 @@
:active="stage >= 3"
animate-by="letters"
:delay="40"
class-name="sp-greeting"
class-name="sp-greeting calligraphy"
@animation-complete="bumpStage(4)"
/>
@@ -57,7 +57,7 @@
cursor-character="|"
cursor-class-name="sp-cursor"
:typing-speed="42"
class-name="sp-line"
class-name="sp-line calligraphy"
@animation-complete="onLineDone(i)"
/>
<BlurText
@@ -66,7 +66,7 @@
:active="lineStage > i"
animate-by="words"
:delay="70"
class-name="sp-line"
class-name="sp-line calligraphy"
@animation-complete="onLineDone(i)"
/>
</template>
@@ -74,22 +74,22 @@
<div class="sp-couple" :class="{ 'is-show': stage >= 5 }">
<div class="sp-person">
<span class="sp-role">新郎</span>
<BlurText v-if="stage >= 5" :text="groom" :active="true" animate-by="letters" :delay="40" class-name="sp-name" />
<span class="sp-role calligraphy">新郎</span>
<BlurText v-if="stage >= 5" :text="groom" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" />
</div>
<span class="sp-xi-mid"></span>
<div class="sp-person">
<span class="sp-role">新娘</span>
<BlurText v-if="stage >= 5" :text="bride" :active="true" animate-by="letters" :delay="40" class-name="sp-name" @animation-complete="bumpStage(6)" />
<span class="sp-role calligraphy">新娘</span>
<BlurText v-if="stage >= 5" :text="bride" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" @animation-complete="bumpStage(6)" />
</div>
</div>
<div class="sp-meta" :class="{ 'is-show': stage >= 6 }">
<div class="sp-meta-title">新婚喜宴时间</div>
<div class="sp-meta-val">{{ cfg.dateText }}</div>
<div class="sp-meta-sub">{{ cfg.lunarText }}</div>
<div class="sp-meta-title mt">地点</div>
<div class="sp-meta-val">{{ cfg.venueText }}</div>
<div class="sp-meta-title calligraphy">新婚喜宴时间</div>
<div class="sp-meta-val calligraphy">{{ cfg.dateText }}</div>
<div class="sp-meta-sub calligraphy">{{ cfg.lunarText }}</div>
<div class="sp-meta-title mt calligraphy">地点</div>
<div class="sp-meta-val calligraphy">{{ cfg.venueText }}</div>
</div>
<div class="sp-love" :class="{ 'is-show': stage >= 6 }">

View File

@@ -38,7 +38,7 @@
<DanmakuLayer
ref="danmakuLayerRef"
:enabled="!!data.danmakuEnabled"
:enabled="!!data.danmakuEnabled && showDanmaku"
:show-time="!!data.danmakuShowTime"
:active="effectsActive"
/>
@@ -54,13 +54,32 @@
<span class="scroll-progress-xi calligraphy"></span>
</div>
<!-- 右上角自动滚动 -->
<div class="fixed top-6 right-5 z-50 flex flex-col items-end gap-4">
<!-- 右上角自动滚动 / 弹幕 / 礼物特效 -->
<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"
:class="isAutoScroll ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30'">
: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" />
<img v-else src="https://api.iconify.design/lucide:play.svg?color=%23a88c6b" class="w-4 h-4 ml-0.5" />
</div>
<button
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"
>
<i class="fa-solid fa-comment-dots text-[13px]"></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"
>
<i class="fa-solid fa-gift text-[13px]"></i>
</button>
</div>
<!-- 底部互动区 -->
@@ -89,6 +108,19 @@
</button>
</div>
</div>
<!-- 行1.5礼物 -->
<div class="action-dock-row">
<button
type="button"
class="action-pill gift-fab"
:class="{ busy: giftBusy }"
title="送礼物"
@click="openGiftPanel"
>
<i class="fa-solid fa-gift gift-icon"></i>
<span class="like-count">{{ giftTotal || '礼物' }}</span>
</button>
</div>
<!-- 行2导航 + 回执 -->
<div class="action-dock-row">
<button
@@ -103,7 +135,7 @@
type="button"
class="action-pill action-rsvp"
title="出席回执"
@click="showTrackList = false; isDrawerOpen = true"
@click="showTrackList = false; openRsvpDrawer()"
>
<img src="https://api.iconify.design/lucide:clipboard-pen.svg?color=%23a88c6b" class="action-pill-icon" alt="" />
</button>
@@ -253,6 +285,45 @@
</div>
<LikeBurst ref="likeBurstRef" />
<GiftEffect ref="giftEffectRef" @play-start="onGiftPlayStart" />
<GiftFeed ref="giftFeedRef" />
<!-- 礼物面板 -->
<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="{ '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>
<div class="gift-quota mb-2.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>
</div>
<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]"
/>
<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)"
>
<span class="gift-item-label calligraphy-strong">{{ g.label }}</span>
<span class="gift-item-count">{{ giftCounts[g.key] || 0 }}</span>
</button>
</div>
</div>
<!-- 主滚动区域注意不要加 scroll-smooth它会劫持 JS 滚动赋值导致卡顿 -->
<!-- iOS 自由自动滚时对该节点做 translate3d避免每帧写 scrollTop 与系统合成器打架 -->
@@ -427,7 +498,7 @@
<span class="ending-address">{{ data.address }}</span>
</div>
<div v-reveal="{ variant: 'up', delay: 300 }" class="ending-actions w-full max-w-[280px]">
<button class="rsvp-cta" @click="isDrawerOpen = true">
<button class="rsvp-cta" @click="openRsvpDrawer">
<span class="rsvp-cta-deco"></span>
<span class="rsvp-cta-text">填写出席回执</span>
<span class="rsvp-cta-line"></span>
@@ -622,11 +693,13 @@
<script setup>
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike } from '@/api/wedding'
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike, getGiftStatus, submitGift } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
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 { sendVisitBeacon } from '@/utils/visitBeacon'
import { optimizeImageUrl, isIOS } from '@/composables/useAndroidOptimize'
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
@@ -634,6 +707,8 @@ import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
import CanvasEffects from '@/components/CanvasEffects.vue'
import DanmakuLayer from '@/components/DanmakuLayer.vue'
import LikeBurst from '@/components/LikeBurst.vue'
import GiftEffect from '@/components/GiftEffect.vue'
import GiftFeed from '@/components/GiftFeed.vue'
import PhotoGallery from '@/components/PhotoGallery.vue'
const DEFAULTS = {
@@ -742,14 +817,64 @@ const aiBlessingLoading = ref(false)
const aiBlessingTarget = ref('')
const blessingStyles = BLESSING_STYLES
const likeBurstRef = ref(null)
const giftEffectRef = ref(null)
const giftFeedRef = ref(null)
const likeCount = ref(0)
const likePulse = ref(false)
const likeBusy = ref(false)
let likePulseTimer = null
const giftTypes = GIFT_TYPES
const giftCounts = reactive(emptyGiftCounts())
const giftTotal = ref(0)
const giftBusy = ref(false)
const showGiftPanel = ref(false)
const giftForm = reactive({ name: '' })
const giftQuota = reactive({
my_likes: 0,
gift_used: 0,
gift_allow: 1,
gift_remain: 1,
likes_to_next: 50,
likes_per_gift: 50,
})
const pendingGiftReplay = ref([])
const applyGiftQuota = (quota) => {
if (!quota || typeof quota !== 'object') return
giftQuota.my_likes = Number(quota.my_likes) || 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
}
const DANMAKU_PREF_KEY = 'wedding_show_danmaku_v1'
const GIFT_FX_PREF_KEY = 'wedding_show_gift_fx_v1'
const readPref = (key, fallback = true) => {
try {
const v = localStorage.getItem(key)
if (v === null) return fallback
return v !== '0' && v !== 'false'
} catch {
return fallback
}
}
const showDanmaku = ref(readPref(DANMAKU_PREF_KEY, true))
const showGiftFx = ref(readPref(GIFT_FX_PREF_KEY, true))
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 previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
const albumPreviewImages = ref([])
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
const rsvpForm = reactive({ name: getGuestName(), guest_count: '1', wishes: '' })
const blessingForm = reactive({ name: getGuestName(), content: '', color: DEFAULT_DANMAKU_COLOR })
const danmakuColorOptions = DANMAKU_COLORS
const danmakuGradientOptions = DANMAKU_GRADIENT_COLORS
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
@@ -762,8 +887,22 @@ const clearBlessingAutoClose = () => {
blessingCloseLeft.value = 0
}
const syncGuestNameIntoForms = () => {
const n = getGuestName()
if (!n) return
if (!rsvpForm.name.trim()) rsvpForm.name = n
if (!blessingForm.name.trim()) blessingForm.name = n
if (!giftForm.name.trim()) giftForm.name = n
}
const openRsvpDrawer = () => {
syncGuestNameIntoForms()
isDrawerOpen.value = true
}
const openBlessingDrawer = () => {
clearBlessingAutoClose()
syncGuestNameIntoForms()
blessingSubmitted.value = false
showBlessingDrawer.value = true
}
@@ -1018,7 +1157,7 @@ const openImagePreview = async (src) => {
}
const effectsActive = computed(() =>
!isDrawerOpen.value && !showBlessingDrawer.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
!isDrawerOpen.value && !showBlessingDrawer.value && !showGiftPanel.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
)
const closeDrawer = () => { isDrawerOpen.value = false }
@@ -1085,7 +1224,7 @@ const clearBottomReturn = () => {
}
const scheduleBottomReturn = (reset = false) => {
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value || showBlessingDrawer.value) return
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value || showBlessingDrawer.value || showGiftPanel.value) return
if (bottomReturnTimer && !reset) return
clearBottomReturn()
bottomReturnTimer = setTimeout(() => {
@@ -1135,6 +1274,7 @@ const canAutoAdvance = () =>
&& !isSnapping.value
&& !isDrawerOpen.value
&& !showBlessingDrawer.value
&& !showGiftPanel.value
&& !previewVisible.value
&& !showMapOptions.value
&& !showOpenInBrowser.value
@@ -1243,6 +1383,15 @@ const completeIntro = () => {
pendingLikeBurst = 0
nextTick(() => likeBurstRef.value?.burst(n))
}
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()
}
@@ -1334,6 +1483,8 @@ const handleSubmitRsvp = async () => {
try {
const res = await submitRsvp(rsvpForm)
if (res.code === 200) {
setGuestName(rsvpForm.name)
syncGuestNameIntoForms()
if (res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
isSubmitted.value = true
}
@@ -1351,6 +1502,8 @@ const handleSubmitBlessing = async () => {
color: blessingForm.color || DEFAULT_DANMAKU_COLOR,
})
if (res.code === 200) {
setGuestName(blessingForm.name)
syncGuestNameIntoForms()
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
blessingSubmitted.value = true
scheduleBlessingAutoClose()
@@ -1363,6 +1516,87 @@ const handleSubmitBlessing = async () => {
let pendingLikeBurst = 0
const applyGiftCounts = (counts, total) => {
const next = emptyGiftCounts()
if (counts && typeof counts === 'object') {
for (const k of Object.keys(next)) {
next[k] = Number(counts[k]) || 0
}
}
Object.assign(giftCounts, next)
giftTotal.value = Number(total) || Object.values(next).reduce((a, b) => a + b, 0)
}
const openGiftPanel = async () => {
syncGuestNameIntoForms()
giftForm.name = getGuestName() || giftForm.name
showGiftPanel.value = true
try {
const res = await getGiftStatus(getClientId())
applyGiftCounts(res.data?.counts, res.data?.total)
applyGiftQuota(res.data?.quota)
} catch { /* ignore */ }
}
const onGiftPlayStart = ({ giftType, name, count }) => {
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 (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 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)
closeGiftPanel()
if (showGiftFx.value) {
giftEffectRef.value?.play({ giftType, name, count: 1 })
} else {
giftFeedRef.value?.push(name, giftType, 1)
}
toast.success('心意已送达')
} catch (e) {
toast.error(e.message || '送礼失败')
try {
const res = await getGiftStatus(getClientId())
applyGiftQuota(res.data?.quota)
} catch { /* ignore */ }
} finally {
giftBusy.value = false
}
}
const loadLikeStatus = async () => {
try {
const res = await getLikeStatus(getClientId())
@@ -1375,6 +1609,20 @@ const loadLikeStatus = async () => {
} catch { /* ignore */ }
}
const loadGiftStatus = async () => {
try {
const res = await getGiftStatus(getClientId())
applyGiftCounts(res.data?.counts, res.data?.total)
applyGiftQuota(res.data?.quota)
const recent = Array.isArray(res.data?.recent) ? res.data.recent : []
const aggregated = aggregateGiftRecent(recent)
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 () => {
if (likeBusy.value) return
likeBusy.value = true
@@ -1385,6 +1633,12 @@ const handleLike = async () => {
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
} catch (e) {
toast.error(e.message || '点赞失败')
} finally {
@@ -1439,6 +1693,7 @@ onMounted(async () => {
// 改为用户首次点开预览时再加载loadAlbumPreviewImages 内部有缓存保护)
albumPreviewImages.value = []
loadLikeStatus()
loadGiftStatus()
sendVisitBeacon()
if (data.value.introEnabled) showIntro.value = true
@@ -1505,9 +1760,9 @@ watch(
)
watch(freeScrollSignature, restartAutoScroll)
watch(
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
() => [isDrawerOpen.value, showBlessingDrawer.value, showGiftPanel.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
() => {
if (isDrawerOpen.value || showBlessingDrawer.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) {
if (isDrawerOpen.value || showBlessingDrawer.value || showGiftPanel.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) {
cancelAnimationFrame(animId)
clearBottomReturn()
}
@@ -1579,7 +1834,7 @@ watch(
right: 0;
bottom: 0;
width: min(56vw, 240px);
height: calc(200px + env(safe-area-inset-bottom, 0px));
height: calc(260px + env(safe-area-inset-bottom, 0px));
pointer-events: auto;
z-index: 1;
background: transparent;
@@ -1637,6 +1892,83 @@ watch(
line-height: 1; text-align: center; max-width: 100%;
}
.like-fab.liked .like-count { color: #f472b6; font-weight: 600; }
.gift-fab {
flex-direction: column;
gap: 3px;
padding: 8px 12px;
min-width: 52px;
}
.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);
overflow-y: auto;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
.gift-panel-handle {
width: 36px;
height: 4px;
border-radius: 999px;
background: rgba(168, 140, 107, 0.28);
margin: 0 auto 10px;
}
.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;
letter-spacing: 0.04em;
}
.gift-quota b { color: #a88c6b; font-weight: 600; }
.gift-quota-sub {
font-size: 10px;
color: #8A8680;
letter-spacing: 0.02em;
}
.gift-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.gift-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 52px;
padding: 6px 4px;
border-radius: 10px;
border: 0.5px solid rgba(168, 140, 107, 0.22);
background: #fff;
color: #5c4a35;
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;
letter-spacing: 0.04em;
text-align: center;
}
.gift-item-count {
font-size: 10px;
color: #8A8680;
letter-spacing: 0.04em;
}
@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; }
}
.action-comment-row {
position: relative;
z-index: 2;