1. 礼物特效
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user