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

Binary file not shown.

View File

@@ -35,6 +35,7 @@ func migrateSchema(db *gorm.DB) {
{&models.Danmaku{}, "祝福弹幕", "danmakus"},
{&models.SiteLike{}, "站点点赞", "site_likes"},
{&models.SiteGift{}, "站点礼物", "site_gifts"},
{&models.GiftConfig{}, "礼物兑换配置", "gift_configs"},
{&models.AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"},
{&visit.SiteVisit{}, "访客记录", "site_visits"},
}

View File

@@ -71,11 +71,21 @@ type SiteGift struct {
GiftType string `gorm:"column:gift_type;size:32;not null;index;comment:礼物类型" json:"gift_type"`
Name string `gorm:"column:name;size:50;not null;default:'';comment:送礼人姓名" json:"name"`
ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"`
Cost int64 `gorm:"column:cost;not null;default:0;comment:兑换消耗的点赞数" json:"cost"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:送礼时间" json:"created_at"`
}
func (SiteGift) TableName() string { return "site_gifts" }
// GiftConfig 礼物兑换配置(各礼物所需点赞数)
type GiftConfig struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
CostsData string `gorm:"type:json;column:costs_data;not null;comment:礼物代价JSON map" json:"costs_data"`
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"`
}
func (GiftConfig) TableName() string { return "gift_configs" }
// AiModerationCache 缓存 AI 审核通过/失败结果
type AiModerationCache struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`

View File

@@ -86,39 +86,96 @@ func loadGiftCounts() map[string]int64 {
return counts
}
// defaultGiftCosts 默认三阶梯50 / 100 / 200
var defaultGiftCosts = map[string]int64{
"520": 50,
"forever": 50,
"bald": 50,
"concentric": 50,
"1314": 100,
"heaven": 100,
"moisten": 100,
"match": 200,
}
const (
giftBaseAllow int64 = 1
giftLikesPerExtra int64 = 50
giftCostMin int64 = 1
giftCostMax int64 = 10000
likeBatchMax = 200
)
// giftQuota 每个 client 基础 1 次赠送,每点赞 50 次多 1 次
func giftQuota(clientID string) (myLikes, used, allow, remain, likesToNext int64) {
func defaultGiftCostsCopy() map[string]int64 {
out := make(map[string]int64, len(defaultGiftCosts))
for k, v := range defaultGiftCosts {
out[k] = v
}
return out
}
func loadGiftCosts() map[string]int64 {
costs := defaultGiftCostsCopy()
var cfg models.GiftConfig
if err := db.First(&cfg, 1).Error; err != nil || strings.TrimSpace(cfg.CostsData) == "" {
return costs
}
parsed := map[string]int64{}
if err := json.Unmarshal([]byte(cfg.CostsData), &parsed); err != nil {
return costs
}
for k := range giftTypeWhitelist {
if v, ok := parsed[k]; ok && v >= giftCostMin && v <= giftCostMax {
costs[k] = v
}
}
return costs
}
func saveGiftCosts(costs map[string]int64) error {
normalized := defaultGiftCostsCopy()
for k := range giftTypeWhitelist {
if v, ok := costs[k]; ok && v >= giftCostMin && v <= giftCostMax {
normalized[k] = v
}
}
raw, err := json.Marshal(normalized)
if err != nil {
return err
}
var cfg models.GiftConfig
if err := db.First(&cfg, 1).Error; err != nil {
cfg = models.GiftConfig{ID: 1, CostsData: string(raw)}
return db.Create(&cfg).Error
}
return db.Model(&cfg).Update("costs_data", string(raw)).Error
}
// giftBalance 点赞余额:可用 = 我的点赞 - 已兑换消耗
func giftBalance(clientID string) (myLikes, spent, available int64) {
clientID = strings.TrimSpace(clientID)
if clientID != "" {
if clientID == "" {
return 0, 0, 0
}
db.Model(&models.SiteLike{}).Where("client_id = ?", clientID).Count(&myLikes)
db.Model(&models.SiteGift{}).Where("client_id = ?", clientID).Count(&used)
}
allow = giftBaseAllow + myLikes/giftLikesPerExtra
remain = allow - used
if remain < 0 {
remain = 0
}
likesToNext = giftLikesPerExtra - (myLikes % giftLikesPerExtra)
if likesToNext <= 0 {
likesToNext = giftLikesPerExtra
db.Model(&models.SiteGift{}).Where("client_id = ?", clientID).Select("COALESCE(SUM(cost), 0)").Scan(&spent)
available = myLikes - spent
if available < 0 {
available = 0
}
return
}
func giftQuotaPayload(clientID string) gin.H {
myLikes, used, allow, remain, likesToNext := giftQuota(clientID)
myLikes, spent, available := giftBalance(clientID)
var giftUsed int64
if strings.TrimSpace(clientID) != "" {
db.Model(&models.SiteGift{}).Where("client_id = ?", clientID).Count(&giftUsed)
}
return gin.H{
"my_likes": myLikes,
"gift_used": used,
"gift_allow": allow,
"gift_remain": remain,
"likes_to_next": likesToNext,
"likes_per_gift": giftLikesPerExtra,
"spent": spent,
"available": available,
"gift_used": giftUsed,
"costs": loadGiftCosts(),
}
}
@@ -156,6 +213,8 @@ func registerAPI(api *gin.RouterGroup) {
api.GET("/gift", handleGiftStatus)
api.POST("/gift", handleCreateGift)
api.GET("/gift/stats", handleGiftStats)
api.GET("/gift/costs", handleGetGiftCosts)
api.POST("/gift/costs", handleSaveGiftCosts)
visit.Register(api, db, utils.RequireAdmin)
configsection.Register(api, db, utils.RequireAdmin)
@@ -457,6 +516,7 @@ func handleLikeCount(c *gin.Context) {
func handleLike(c *gin.Context) {
var body struct {
ClientID string `json:"client_id"`
Count int `json:"count"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
@@ -467,13 +527,34 @@ func handleLike(c *gin.Context) {
c.JSON(400, gin.H{"error": "无效的 client_id"})
return
}
if err := db.Create(&models.SiteLike{ClientID: clientID}).Error; err != nil {
n := body.Count
if n <= 0 {
n = 1
}
if n > likeBatchMax {
c.JSON(400, gin.H{"error": fmt.Sprintf("单次最多点赞 %d 次", likeBatchMax)})
return
}
rows := make([]models.SiteLike, n)
now := time.Now()
for i := 0; i < n; i++ {
rows[i] = models.SiteLike{ClientID: clientID, CreatedAt: now}
}
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
c.JSON(500, gin.H{"error": "点赞失败"})
return
}
var count int64
db.Model(&models.SiteLike{}).Count(&count)
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"})
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"count": count,
"added": n,
"quota": giftQuotaPayload(clientID),
},
"msg": "点赞成功",
})
}
func handleGiftStatus(c *gin.Context) {
@@ -541,10 +622,16 @@ func handleCreateGift(c *gin.Context) {
c.JSON(400, gin.H{"error": "无效的 client_id"})
return
}
_, _, _, remain, likesToNext := giftQuota(clientID)
if remain <= 0 {
costs := loadGiftCosts()
cost := costs[giftType]
if cost < giftCostMin {
cost = defaultGiftCosts[giftType]
}
_, _, available := giftBalance(clientID)
if available < cost {
need := cost - available
c.JSON(400, gin.H{
"error": fmt.Sprintf("赠送次数已用完,再点赞 %d 次可多送 1 次", likesToNext),
"error": fmt.Sprintf("点赞不足,再点赞 %d 次可兑换「%s」", need, giftTypeWhitelist[giftType]),
"data": giftQuotaPayload(clientID),
})
return
@@ -553,6 +640,7 @@ func handleCreateGift(c *gin.Context) {
GiftType: giftType,
Name: name,
ClientID: clientID,
Cost: cost,
}).Error; err != nil {
c.JSON(500, gin.H{"error": "送礼失败"})
return
@@ -568,6 +656,7 @@ func handleCreateGift(c *gin.Context) {
"counts": counts,
"total": total,
"gift_type": giftType,
"cost": cost,
"quota": giftQuotaPayload(clientID),
},
"msg": "送礼成功",
@@ -593,10 +682,60 @@ func handleGiftStats(c *gin.Context) {
"counts": counts,
"labels": labels,
"total": total,
"costs": loadGiftCosts(),
},
})
}
func handleGetGiftCosts(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
labels := make(map[string]string, len(giftTypeWhitelist))
for k, v := range giftTypeWhitelist {
labels[k] = v
}
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"costs": loadGiftCosts(),
"labels": labels,
"defaults": defaultGiftCostsCopy(),
},
})
}
func handleSaveGiftCosts(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
var body struct {
Costs map[string]int64 `json:"costs"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Costs == nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
for k, v := range body.Costs {
if _, ok := giftTypeWhitelist[k]; !ok {
continue
}
if v < giftCostMin || v > giftCostMax {
c.JSON(400, gin.H{"error": fmt.Sprintf("「%s」兑换点赞需在 %d%d 之间", giftTypeWhitelist[k], giftCostMin, giftCostMax)})
return
}
}
if err := saveGiftCosts(body.Costs); err != nil {
c.JSON(500, gin.H{"error": "保存失败"})
return
}
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{"costs": loadGiftCosts()},
"msg": "礼物兑换配置已保存",
})
}
func normalizeDanmakuColor(color string) string {
c := strings.TrimSpace(color)
if c == "rose" || c == "sage" {

View File

@@ -134,8 +134,8 @@ export function getLikeStatus(clientId) {
return service.get('/like', { params: { client_id: clientId } })
}
export function submitLike(clientId) {
return service.post('/like', { client_id: clientId })
export function submitLike(clientId, count = 1) {
return service.post('/like', { client_id: clientId, count })
}
export function getGiftStatus(clientId) {
@@ -154,6 +154,14 @@ export function getGiftStats() {
return service.get('/gift/stats')
}
export function getGiftCosts() {
return service.get('/gift/costs')
}
export function saveGiftCosts(costs) {
return service.post('/gift/costs', { costs })
}
export function adminLogin(credentials) {
return service.post('/admin/login', credentials)
}

View File

@@ -1,5 +1,6 @@
<template>
<div v-if="enabled && (items.length || rows.length)" class="danmaku-layer" aria-hidden="true">
<!-- 始终挂载 v-show 隐藏避免关闭时 v-if 拆树与清空列表打架 -->
<div v-show="enabled" class="danmaku-layer" aria-hidden="true">
<div
v-for="row in rows"
:key="row.id"
@@ -142,7 +143,6 @@ const buildRow = (item, { force = false } = {}) => {
const key = itemKey(item)
if (!force && isCooling(key)) return null
// 速度区间收窄,降低同屏追尾概率;轨道占用保证同轨不叠
const duration = 12 + Math.random() * 4
const delay = force ? 0 : Math.random() * 0.25
const slot = reserveTrack(duration, delay, { force })
@@ -154,7 +154,6 @@ const buildRow = (item, { force = false } = {}) => {
const tint = resolveDanmakuTint(item.color)
const id = ++uid
const totalDelay = delay + slot.extraDelay
// 5 轨均匀分布在上半屏,轨间距约 14%
const topPct = 7 + slot.track * 14
const isGradient = typeof tint.background === 'string' && tint.background.includes('gradient')
@@ -191,11 +190,7 @@ const mergeItems = (incoming) => {
}
const fetchList = async () => {
if (!props.enabled) {
items.value = []
shownIds.clear()
return
}
if (!props.enabled) return
try {
const done = isBatchDone()
const maxId = maxItemId()
@@ -229,9 +224,7 @@ const spawnOne = () => {
const pushItem = (item) => {
if (!item || !props.enabled) return
const id = item.id
if (id != null && items.value.some((x) => x.id === id)) {
// already in list — still force-spawn for sender
} else {
if (id == null || !items.value.some((x) => x.id === id)) {
items.value = [...items.value, item]
}
if (!props.active) return
@@ -240,6 +233,7 @@ const pushItem = (item) => {
}
const onEnded = (id) => {
if (!rows.value.length) return
rows.value = rows.value.filter((r) => r.id !== id)
}
@@ -270,52 +264,70 @@ const stopPoll = () => {
pollTimer = null
}
watch(
() => [props.enabled, props.active, items.value.length],
() => {
if (props.enabled && props.active && items.value.length) startSpawn()
else {
stopSpawn()
if (!props.enabled) {
const clearFlying = () => {
rows.value = []
resetTracks()
}
const clearAll = () => {
stopPoll()
stopSpawn()
clearFlying()
items.value = []
lastShownAt.clear()
shownIds.clear()
resetTracks()
cursor = 0
}
}
}
)
const syncSpawn = () => {
if (props.enabled && props.active && items.value.length) startSpawn()
else stopSpawn()
}
// 只监听开关本身,禁止在依赖 items.length 的 watcher 里清空 items会递归更新
watch(
() => props.enabled,
async (on) => {
if (on) {
await fetchList()
startPoll()
if (props.active && items.value.length) startSpawn()
syncSpawn()
} else {
stopPoll()
clearAll()
}
},
)
watch(
() => props.active,
() => {
if (!props.enabled) return
if (props.active) syncSpawn()
else {
stopSpawn()
rows.value = []
items.value = []
lastShownAt.clear()
shownIds.clear()
resetTracks()
}
clearFlying()
}
},
)
// items 变化时只启停 spawn绝不在这里清空 items
watch(
() => items.value.length,
() => {
if (!props.enabled) return
syncSpawn()
},
)
onMounted(async () => {
if (!props.enabled) return
await fetchList()
startPoll()
if (props.active && items.value.length) startSpawn()
syncSpawn()
})
onUnmounted(() => {
stopPoll()
stopSpawn()
clearAll()
})
defineExpose({ refresh: fetchList, pushItem })

View File

@@ -206,12 +206,15 @@ function resizeCanvas() {
H = cssH
canvas.style.width = `${cssW}px`
canvas.style.height = `${cssH}px`
canvas.style.writingMode = 'horizontal-tb'
canvas.style.direction = 'ltr'
canvas.width = Math.max(1, Math.floor(W * dpr))
canvas.height = Math.max(1, Math.floor(H * dpr))
ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) return
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
if (typeof ctx.direction === 'string') ctx.direction = 'ltr'
}
function drawHeartPath(context, cx, cy, size) {
@@ -277,28 +280,80 @@ function scrolledCount(mt, now) {
return Math.max(from, Math.round(from + (target - from) * e))
}
const FONT_STACK = '"Ma Shan Zheng", "PingFang SC", "Hiragino Sans GB", sans-serif'
function setMilestoneFont(context, fontSize) {
// Ma Shan Zheng 只有 400用 700 时 iOS 会假粗并打乱字距/排版
context.font = `400 ${fontSize}px ${FONT_STACK}`
if (typeof context.direction === 'string') context.direction = 'ltr'
if (typeof context.letterSpacing === 'string') context.letterSpacing = '0px'
}
/** 保证艺术字单行:按字宽横排,过宽则缩小字号 */
function fitMilestoneFontSize(context, text, desired, maxWidth) {
let size = Math.max(12, desired)
setMilestoneFont(context, size)
let w = context.measureText(text).width
while (size > 14 && w > maxWidth) {
size -= 2
setMilestoneFont(context, size)
w = context.measureText(text).width
}
return size
}
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`
const raw = String(text || '').replace(/[\r\n\u2028\u2029]/g, '')
if (!raw) return
// iOS canvas shadowBlur 在中文笔画下会拖出脏光晕,艺术字直接关掉阴影
const blur = isIOS ? 0 : Math.max(0, glowBlur * SHADOW_MUL)
const maxW = Math.max(40, W * 0.88)
const size = fitMilestoneFontSize(context, raw, fontSize, maxW)
setMilestoneFont(context, size)
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)
context.lineJoin = 'round'
context.miterLimit = 2
context.lineWidth = size * (isIOS ? 0.08 : 0.065)
context.shadowColor = 'transparent'
context.shadowBlur = 0
context.shadowOffsetX = 0
context.shadowOffsetY = 0
context.strokeStyle = strokeColor
const chars = Array.from(raw)
// 逐字测宽后强制横排,避免 iOS 把四字折成 2×2
const widths = chars.map((ch) => context.measureText(ch).width)
const total = widths.reduce((a, b) => a + b, 0)
const textGrad = context.createLinearGradient(0, y - size * 0.55, 0, y + size * 0.55)
textGrad.addColorStop(0, gradient[0])
textGrad.addColorStop(0.35, gradient[1])
textGrad.addColorStop(0.7, gradient[2])
textGrad.addColorStop(1, gradient[3])
let x = -total / 2
for (let i = 0; i < chars.length; i += 1) {
const ch = chars[i]
const cw = widths[i]
const cx = x + cw / 2
if (blur > 0) {
context.shadowColor = glowColor
context.shadowBlur = blur
}
context.strokeText(ch, cx, y)
context.fillStyle = textGrad
context.shadowBlur = blur * 1.5
context.fillText(text, 0, y)
if (blur > 0) context.shadowBlur = blur * 1.5
context.fillText(ch, cx, y)
context.shadowColor = 'transparent'
context.shadowBlur = 0
context.fillStyle = 'rgba(255,255,255,0.4)'
context.fillText(text, -fontSize * 0.018, y - fontSize * 0.028)
context.fillText(ch, cx - size * 0.018, y - size * 0.028)
x += cw
}
context.shadowColor = 'transparent'
context.shadowBlur = 0
context.shadowOffsetX = 0
context.shadowOffsetY = 0
}
function textProgress(t, now) {
@@ -984,7 +1039,21 @@ onUnmounted(() => {
rings = []
})
defineExpose({ play, playSequence })
function clearAll() {
clearTimeout(queueTimer)
queueTimer = null
playQueue = []
activeMeta = null
clearVisuals()
running = false
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
}
defineExpose({ play, playSequence, clear: clearAll, stop: clearAll })
</script>
<style scoped>
@@ -997,6 +1066,11 @@ defineExpose({ play, playSequence })
height: 100dvh;
z-index: 46;
pointer-events: none;
/* 防止 iOS 继承竖排书写模式导致 canvas 文字折成两行/方阵 */
writing-mode: horizontal-tb;
text-orientation: mixed;
direction: ltr;
white-space: nowrap;
/* iOS独立合成层避免和左侧点赞 canvas / transform 滚动打架 */
transform: translateZ(0);
-webkit-transform: translateZ(0);

View File

@@ -1,6 +1,11 @@
<template>
<div class="gift-feed" aria-live="polite">
<TransitionGroup name="gift-feed-item" tag="div" class="gift-feed-list">
<TransitionGroup
name="gift-feed-item"
tag="div"
class="gift-feed-list"
:css="useTransition"
>
<div
v-for="item in items"
:key="item.id"
@@ -21,7 +26,7 @@
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { ref, nextTick, onUnmounted } from 'vue'
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
const MAX_ITEMS = 5
@@ -30,6 +35,7 @@ const ROLL_MS = 900
const items = ref([])
const timers = new Map()
const rollTimers = new Map()
const useTransition = ref(true)
let seq = 0
const baseLabel = (giftType) => GIFT_LABEL_MAP[giftType] || giftType
@@ -146,14 +152,28 @@ const bumpOrPush = (name, giftType) => {
return push(n, type, 1)
}
const clear = () => {
timers.forEach((t) => clearTimeout(t))
timers.clear()
rollTimers.forEach((r) => cancelAnimationFrame(r))
rollTimers.clear()
// 一次性清空时关掉 leave 过渡,避免 TransitionGroup 与 vnode patch 冲突
useTransition.value = false
items.value = []
nextTick(() => {
useTransition.value = true
})
}
onUnmounted(() => {
timers.forEach((t) => clearTimeout(t))
timers.clear()
rollTimers.forEach((r) => cancelAnimationFrame(r))
rollTimers.clear()
items.value = []
})
defineExpose({ push, revealCount, bumpOrPush })
defineExpose({ push, revealCount, bumpOrPush, clear })
</script>
<style scoped>

View File

@@ -316,6 +316,8 @@ function fireOneLikeEffect() {
return true
}
let burstGapMs = LIKE_GAP_MS
const scheduleNextLike = () => {
clearLikeTimer()
if (firedLikes >= targetLikes) return
@@ -328,19 +330,37 @@ const scheduleNextLike = () => {
}
firedLikes += 1
if (firedLikes < targetLikes) scheduleNextLike()
}, LIKE_GAP_MS)
}, burstGapMs)
}
/**
* 按真实点赞次数逐次触发飘心(不是一次性铺满)
* @param {number} count
* @param {{ durationMs?: number, maxVisual?: number }} [opts]
* durationMs: 把视觉节奏压缩到约几秒内播完(多次点赞用)
* maxVisual: 最多触发几次飘心节奏
*/
const burst = (count = 3) => {
const burst = (count = 3, opts = {}) => {
clearLikeTimer()
const n = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
if (!n) return
const raw = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
if (!raw) return
if (!W || !H) resizeCanvas()
targetLikes = n
const maxVisual = Math.max(1, Math.min(
Number(opts.maxVisual) || raw,
raw,
24,
))
const durationMs = Number(opts.durationMs)
const useShort = Number.isFinite(durationMs) && durationMs > 0 && raw > 1
const visual = useShort ? Math.min(maxVisual, Math.max(5, Math.ceil(Math.min(raw, 12)))) : Math.min(raw, maxVisual)
targetLikes = visual
firedLikes = 0
burstGapMs = useShort
? Math.max(90, Math.floor(durationMs / Math.max(1, visual)))
: LIKE_GAP_MS
if (fireOneLikeEffect()) firedLikes = 1
if (firedLikes < targetLikes) scheduleNextLike()
}

View File

@@ -1,21 +1,30 @@
/** 礼物类型常量表(与后端 giftTypeWhitelist 一致) */
/** 礼物类型常量表(与后端 giftTypeWhitelist / defaultGiftCosts 一致) */
export const GIFT_TYPES = [
{ 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%)' },
{ key: '520', label: '520', cost: 50, preview: 'linear-gradient(135deg, #ff8da6 0%, #ff2d55 55%, #c41e3a 100%)' },
{ key: '1314', label: '1314', cost: 100, preview: 'linear-gradient(135deg, #fff3b0 0%, #ffd700 50%, #e6a000 100%)' },
{ key: 'forever', label: '长长久久', cost: 50, preview: 'linear-gradient(135deg, #ffb3c1 0%, #c41e3a 55%, #6b0f1a 100%)' },
{ key: 'bald', label: '白头偕老', cost: 50, preview: 'linear-gradient(135deg, #ffffff 0%, #e8e4d9 45%, #b8a878 100%)' },
{ key: 'concentric', label: '永结同心', cost: 50, preview: 'linear-gradient(135deg, #f5dcc4 0%, #d4a574 50%, #b76e79 100%)' },
{ key: 'heaven', label: '天作之合', cost: 100, preview: 'linear-gradient(135deg, #cce4ff 0%, #5ba3ff 50%, #1a5bb5 100%)' },
{ key: 'moisten', label: '相濡以沫', cost: 100, preview: 'linear-gradient(135deg, #c5f5ef 0%, #40b4a6 50%, #165e5a 100%)' },
{ key: 'match', label: '佳偶天成', cost: 200, 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]))
export const DEFAULT_GIFT_COSTS = Object.fromEntries(GIFT_TYPES.map((g) => [g.key, g.cost]))
/** 快捷点赞气泡档位 */
export const LIKE_QUICK_AMOUNTS = [10, 50, 100]
export function emptyGiftCounts() {
return Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0]))
}
export function emptyGiftCosts() {
return { ...DEFAULT_GIFT_COSTS }
}
/**
* 连续相同「姓名 + 礼物」合并为一条(带 count用于串行特效与左下角展示
* @param {Array<{ gift_type?: string, name?: string }>} recent
@@ -36,3 +45,12 @@ export function aggregateGiftRecent(recent) {
}
return out
}
/** 礼物分页:每页 4 个,便于横向翻页 */
export function chunkGiftTypes(types = GIFT_TYPES, pageSize = 4) {
const pages = []
for (let i = 0; i < types.length; i += pageSize) {
pages.push(types.slice(i, i + pageSize))
}
return pages
}

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,22 +327,30 @@
<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">
<div
ref="giftPagesRef"
class="gift-pages"
@scroll.passive="onGiftPagesScroll"
>
<div
v-for="(page, pi) in giftPages"
:key="pi"
class="gift-page"
>
<button
v-for="g in giftTypes"
v-for="g in page"
:key="g.key"
type="button"
class="gift-item"
:disabled="giftBusy || giftQuota.remain <= 0"
:disabled="giftBusy || giftQuota.available < giftCostOf(g.key)"
@click="handleSendGift(g.key)"
>
<span
@@ -324,9 +358,19 @@
: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>
<!-- 主滚动区域注意不要加 scroll-smooth它会劫持 JS 滚动赋值导致卡顿 -->
<!-- iOS 自由自动滚时对该节点做 translate3d避免每帧写 scrollTop 与系统合成器打架 -->
@@ -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;