1. 礼物特效
This commit is contained in:
@@ -34,6 +34,7 @@ func migrateSchema(db *gorm.DB) {
|
||||
{&models.Rsvp{}, "出席回执", "rsvps"},
|
||||
{&models.Danmaku{}, "祝福弹幕", "danmakus"},
|
||||
{&models.SiteLike{}, "站点点赞", "site_likes"},
|
||||
{&models.SiteGift{}, "站点礼物", "site_gifts"},
|
||||
{&models.AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"},
|
||||
{&visit.SiteVisit{}, "访客记录", "site_visits"},
|
||||
}
|
||||
|
||||
@@ -65,6 +65,17 @@ type SiteLike struct {
|
||||
|
||||
func (SiteLike) TableName() string { return "site_likes" }
|
||||
|
||||
// SiteGift 站点礼物记录
|
||||
type SiteGift struct {
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
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"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;comment:送礼时间" json:"created_at"`
|
||||
}
|
||||
|
||||
func (SiteGift) TableName() string { return "site_gifts" }
|
||||
|
||||
// AiModerationCache 缓存 AI 审核通过/失败结果
|
||||
type AiModerationCache struct {
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
|
||||
@@ -47,6 +47,81 @@ var danmakuColorWhitelist = map[string]struct{}{
|
||||
"gradOcean": {}, "gradAurora": {}, "gradEmber": {},
|
||||
}
|
||||
|
||||
// giftTypeWhitelist 礼物类型白名单(与前端常量表一致)
|
||||
var giftTypeWhitelist = map[string]string{
|
||||
"520": "520",
|
||||
"1314": "1314",
|
||||
"forever": "长长久久",
|
||||
"bald": "白头偕老",
|
||||
"concentric": "永结同心",
|
||||
"heaven": "天作之合",
|
||||
"moisten": "相濡以沫",
|
||||
"match": "佳偶天成",
|
||||
}
|
||||
|
||||
func emptyGiftCounts() map[string]int64 {
|
||||
counts := make(map[string]int64, len(giftTypeWhitelist))
|
||||
for k := range giftTypeWhitelist {
|
||||
counts[k] = 0
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func loadGiftCounts() map[string]int64 {
|
||||
counts := emptyGiftCounts()
|
||||
type row struct {
|
||||
GiftType string `gorm:"column:gift_type"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var rows []row
|
||||
db.Model(&models.SiteGift{}).
|
||||
Select("gift_type, COUNT(*) as cnt").
|
||||
Group("gift_type").
|
||||
Find(&rows)
|
||||
for _, r := range rows {
|
||||
if _, ok := giftTypeWhitelist[r.GiftType]; ok {
|
||||
counts[r.GiftType] = r.Cnt
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const (
|
||||
giftBaseAllow int64 = 1
|
||||
giftLikesPerExtra int64 = 50
|
||||
)
|
||||
|
||||
// giftQuota 每个 client 基础 1 次赠送,每点赞 50 次多 1 次
|
||||
func giftQuota(clientID string) (myLikes, used, allow, remain, likesToNext int64) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID != "" {
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func giftQuotaPayload(clientID string) gin.H {
|
||||
myLikes, used, allow, remain, likesToNext := giftQuota(clientID)
|
||||
return gin.H{
|
||||
"my_likes": myLikes,
|
||||
"gift_used": used,
|
||||
"gift_allow": allow,
|
||||
"gift_remain": remain,
|
||||
"likes_to_next": likesToNext,
|
||||
"likes_per_gift": giftLikesPerExtra,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 挂载静态资源、中间件与全部 /api 路由
|
||||
func Register(r *gin.Engine, deps Deps) {
|
||||
db = deps.DB
|
||||
@@ -78,6 +153,9 @@ func registerAPI(api *gin.RouterGroup) {
|
||||
api.POST("/danmaku/:id/status", handleDanmakuStatus)
|
||||
api.GET("/like", handleLikeCount)
|
||||
api.POST("/like", handleLike)
|
||||
api.GET("/gift", handleGiftStatus)
|
||||
api.POST("/gift", handleCreateGift)
|
||||
api.GET("/gift/stats", handleGiftStats)
|
||||
|
||||
visit.Register(api, db, utils.RequireAdmin)
|
||||
configsection.Register(api, db, utils.RequireAdmin)
|
||||
@@ -398,6 +476,127 @@ func handleLike(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"})
|
||||
}
|
||||
|
||||
func handleGiftStatus(c *gin.Context) {
|
||||
counts := loadGiftCounts()
|
||||
var recent []models.SiteGift
|
||||
db.Model(&models.SiteGift{}).
|
||||
Order("id DESC").
|
||||
Limit(20).
|
||||
Find(&recent)
|
||||
items := make([]gin.H, 0, len(recent))
|
||||
for _, g := range recent {
|
||||
items = append(items, gin.H{
|
||||
"gift_type": g.GiftType,
|
||||
"name": g.Name,
|
||||
"created_at": g.CreatedAt,
|
||||
})
|
||||
}
|
||||
// 回放按时间正序(最早在前)
|
||||
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
var total int64
|
||||
for _, n := range counts {
|
||||
total += n
|
||||
}
|
||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||
quota := giftQuotaPayload(clientID)
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"counts": counts,
|
||||
"total": total,
|
||||
"recent": items,
|
||||
"quota": quota,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateGift(c *gin.Context) {
|
||||
var body struct {
|
||||
GiftType string `json:"gift_type"`
|
||||
Name string `json:"name"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
giftType := strings.TrimSpace(body.GiftType)
|
||||
if _, ok := giftTypeWhitelist[giftType]; !ok {
|
||||
c.JSON(400, gin.H{"error": "无效的礼物类型"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(400, gin.H{"error": "请填写姓名"})
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > 50 {
|
||||
c.JSON(400, gin.H{"error": "姓名过长"})
|
||||
return
|
||||
}
|
||||
clientID := strings.TrimSpace(body.ClientID)
|
||||
if clientID == "" || len(clientID) > 64 {
|
||||
c.JSON(400, gin.H{"error": "无效的 client_id"})
|
||||
return
|
||||
}
|
||||
_, _, _, remain, likesToNext := giftQuota(clientID)
|
||||
if remain <= 0 {
|
||||
c.JSON(400, gin.H{
|
||||
"error": fmt.Sprintf("赠送次数已用完,再点赞 %d 次可多送 1 次", likesToNext),
|
||||
"data": giftQuotaPayload(clientID),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := db.Create(&models.SiteGift{
|
||||
GiftType: giftType,
|
||||
Name: name,
|
||||
ClientID: clientID,
|
||||
}).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "送礼失败"})
|
||||
return
|
||||
}
|
||||
counts := loadGiftCounts()
|
||||
var total int64
|
||||
for _, n := range counts {
|
||||
total += n
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"counts": counts,
|
||||
"total": total,
|
||||
"gift_type": giftType,
|
||||
"quota": giftQuotaPayload(clientID),
|
||||
},
|
||||
"msg": "送礼成功",
|
||||
})
|
||||
}
|
||||
|
||||
func handleGiftStats(c *gin.Context) {
|
||||
if !utils.RequireAdmin(c) {
|
||||
return
|
||||
}
|
||||
counts := loadGiftCounts()
|
||||
var total int64
|
||||
for _, n := range counts {
|
||||
total += n
|
||||
}
|
||||
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{
|
||||
"counts": counts,
|
||||
"labels": labels,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeDanmakuColor(color string) string {
|
||||
c := strings.TrimSpace(color)
|
||||
if c == "rose" || c == "sage" {
|
||||
|
||||
@@ -138,6 +138,22 @@ export function submitLike(clientId) {
|
||||
return service.post('/like', { client_id: clientId })
|
||||
}
|
||||
|
||||
export function getGiftStatus(clientId) {
|
||||
return service.get('/gift', { params: { client_id: clientId } })
|
||||
}
|
||||
|
||||
export function submitGift({ giftType, name, clientId }) {
|
||||
return service.post('/gift', {
|
||||
gift_type: giftType,
|
||||
name,
|
||||
client_id: clientId,
|
||||
})
|
||||
}
|
||||
|
||||
export function getGiftStats() {
|
||||
return service.get('/gift/stats')
|
||||
}
|
||||
|
||||
export function adminLogin(credentials) {
|
||||
return service.post('/admin/login', credentials)
|
||||
}
|
||||
|
||||
854
vite-tailwindcss/src/components/GiftEffect.vue
Normal file
854
vite-tailwindcss/src/components/GiftEffect.vue
Normal file
@@ -0,0 +1,854 @@
|
||||
<template>
|
||||
<canvas ref="canvasRef" class="gift-effect-canvas" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
|
||||
|
||||
const emit = defineEmits(['play-start'])
|
||||
const canvasRef = ref(null)
|
||||
|
||||
const MAX_PARTICLES = 280
|
||||
const MAX_RINGS = 12
|
||||
const MAX_TEXTS = 3
|
||||
const DPR_CAP = 2
|
||||
|
||||
const GIFT_STYLE = {
|
||||
'520': {
|
||||
duration: 3000,
|
||||
fontSizeRatio: 0.24,
|
||||
fontSizeMax: 120,
|
||||
peakScale: 1.42,
|
||||
flash: 0.75,
|
||||
flashMs: 220,
|
||||
flashColor: 'rgba(255, 80, 130, 0.55)',
|
||||
particleMode: 'heartBurst',
|
||||
palette: ['#ff2442', '#ff2d55', '#ff6b8a', '#ff4770', '#ff1a3d'],
|
||||
glowPalette: ['#ff8da6', '#ffb0c4', '#ff6b9d'],
|
||||
gradient: ['#ffb3c6', '#ff2d55', '#e61a3d', '#b3001e'],
|
||||
glowColor: '#ff6b9d',
|
||||
strokeColor: 'rgba(255,255,255,0.85)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
'1314': {
|
||||
duration: 4000,
|
||||
fontSizeRatio: 0.26,
|
||||
fontSizeMax: 136,
|
||||
peakScale: 1.55,
|
||||
flash: 0.9,
|
||||
flashMs: 320,
|
||||
flashColor: 'rgba(255, 220, 100, 0.6)',
|
||||
particleMode: 'goldenRain',
|
||||
palette: ['#ffd700', '#ffcc00', '#ffb800', '#ffe44d', '#ffab00'],
|
||||
glowPalette: ['#ffe88d', '#fff0a8', '#fff5cc'],
|
||||
gradient: ['#fff8c0', '#ffd700', '#ffb800', '#e6a000'],
|
||||
glowColor: '#ffe88d',
|
||||
strokeColor: 'rgba(255,255,255,0.92)',
|
||||
showHeartOutline: true,
|
||||
orb: true,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
forever: {
|
||||
duration: 3600,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.32,
|
||||
flash: 0.6,
|
||||
flashMs: 260,
|
||||
flashColor: 'rgba(196, 30, 58, 0.45)',
|
||||
particleMode: 'dualWave',
|
||||
palette: ['#c41e3a', '#9b1b30', '#e63946', '#ff6b6b', '#ff2442'],
|
||||
glowPalette: ['#ff8a9a', '#ffb3c1'],
|
||||
gradient: ['#ffb3c1', '#c41e3a', '#9b1b30', '#6b0f1a'],
|
||||
glowColor: '#ff4d6d',
|
||||
strokeColor: 'rgba(255,240,240,0.88)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
bald: {
|
||||
duration: 3800,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.26,
|
||||
flash: 0.45,
|
||||
flashMs: 300,
|
||||
flashColor: 'rgba(245, 240, 224, 0.5)',
|
||||
particleMode: 'softPetals',
|
||||
palette: ['#f5f0e0', '#e8e4d9', '#d4c5a0', '#c9b896', '#fffaf0'],
|
||||
glowPalette: ['#fffaf0', '#f0e6c8'],
|
||||
gradient: ['#ffffff', '#f5f0e0', '#d4c5a0', '#b8a878'],
|
||||
glowColor: '#f5f0e0',
|
||||
strokeColor: 'rgba(180,160,100,0.55)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: false,
|
||||
},
|
||||
concentric: {
|
||||
duration: 3800,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.34,
|
||||
flash: 0.55,
|
||||
flashMs: 240,
|
||||
flashColor: 'rgba(212, 165, 116, 0.45)',
|
||||
particleMode: 'ring',
|
||||
palette: ['#d4a574', '#c9896b', '#e8b4b8', '#b76e79', '#f0d5b8'],
|
||||
glowPalette: ['#f0d5b8', '#f5c6c8'],
|
||||
gradient: ['#f5dcc4', '#d4a574', '#b76e79', '#8b4a52'],
|
||||
glowColor: '#e8c4a0',
|
||||
strokeColor: 'rgba(255,255,255,0.8)',
|
||||
showHeartOutline: false,
|
||||
orb: true,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
heaven: {
|
||||
duration: 3600,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.3,
|
||||
flash: 0.5,
|
||||
flashMs: 280,
|
||||
flashColor: 'rgba(90, 160, 255, 0.4)',
|
||||
particleMode: 'stars',
|
||||
palette: ['#3d8bfd', '#5ba3ff', '#89c2ff', '#4a90a4', '#cce4ff'],
|
||||
glowPalette: ['#a8d4ff', '#cce4ff'],
|
||||
gradient: ['#cce4ff', '#5ba3ff', '#3d8bfd', '#1a5bb5'],
|
||||
glowColor: '#7eb6ff',
|
||||
strokeColor: 'rgba(255,255,255,0.88)',
|
||||
showHeartOutline: false,
|
||||
orb: true,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
moisten: {
|
||||
duration: 3800,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.28,
|
||||
flash: 0.42,
|
||||
flashMs: 300,
|
||||
flashColor: 'rgba(64, 180, 166, 0.4)',
|
||||
particleMode: 'ripple',
|
||||
palette: ['#2a9d8f', '#40b4a6', '#5ecfc2', '#1d7874', '#9ee8de'],
|
||||
glowPalette: ['#9ee8de', '#c5f5ef'],
|
||||
gradient: ['#c5f5ef', '#40b4a6', '#2a9d8f', '#165e5a'],
|
||||
glowColor: '#7dd3c7',
|
||||
strokeColor: 'rgba(255,255,255,0.85)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
match: {
|
||||
duration: 3600,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.36,
|
||||
flash: 0.58,
|
||||
flashMs: 260,
|
||||
flashColor: 'rgba(232, 93, 117, 0.45)',
|
||||
particleMode: 'twin',
|
||||
palette: ['#e85d75', '#f0a060', '#ff8a65', '#d4a574', '#ffb3c1'],
|
||||
glowPalette: ['#ffb3c1', '#ffd4a8'],
|
||||
gradient: ['#ffd4a8', '#e85d75', '#d4a574', '#b5475a'],
|
||||
glowColor: '#f0a060',
|
||||
strokeColor: 'rgba(255,255,255,0.88)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
}
|
||||
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]
|
||||
const rand = (min, max) => min + Math.random() * (max - min)
|
||||
|
||||
let ctx = null
|
||||
let W = 0
|
||||
let H = 0
|
||||
let dpr = 1
|
||||
let texts = []
|
||||
let particles = []
|
||||
let rings = []
|
||||
let flashAlpha = 0
|
||||
let flashDecay = 0
|
||||
let flashColor = 'rgba(255,255,255,0.5)'
|
||||
let rafId = 0
|
||||
let running = false
|
||||
let playQueue = []
|
||||
let queueTimer = null
|
||||
let secondaryTimers = []
|
||||
|
||||
function resizeCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
W = rect.width
|
||||
H = rect.height
|
||||
canvas.width = Math.max(1, Math.floor(W * dpr))
|
||||
canvas.height = Math.max(1, Math.floor(H * dpr))
|
||||
ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(dpr, dpr)
|
||||
}
|
||||
|
||||
function drawHeartPath(context, cx, cy, size) {
|
||||
const s = size
|
||||
context.beginPath()
|
||||
context.moveTo(cx, cy + s * 0.55)
|
||||
context.bezierCurveTo(cx - s * 0.85, cy + s * 0.25, cx - s * 0.95, cy - s * 0.2, cx - s * 0.45, cy - s * 1.05)
|
||||
context.bezierCurveTo(cx, cy - s * 0.55, cx + s * 0.45, cy - s * 1.05, cx + s * 0.45, cy - s * 1.05)
|
||||
context.bezierCurveTo(cx + s * 0.95, cy - s * 0.2, cx + s * 0.85, cy + s * 0.25, cx, cy + s * 0.55)
|
||||
context.closePath()
|
||||
}
|
||||
|
||||
function drawPetal(context, x, y, size, rot) {
|
||||
context.save()
|
||||
context.translate(x, y)
|
||||
context.rotate(rot)
|
||||
context.beginPath()
|
||||
context.moveTo(0, -size)
|
||||
context.quadraticCurveTo(size * 0.7, -size * 0.2, 0, size)
|
||||
context.quadraticCurveTo(-size * 0.7, -size * 0.2, 0, -size)
|
||||
context.closePath()
|
||||
context.fill()
|
||||
context.restore()
|
||||
}
|
||||
|
||||
function createTextEffect(giftType, now, count = 1) {
|
||||
const style = GIFT_STYLE[giftType] || GIFT_STYLE['520']
|
||||
const base = GIFT_LABEL_MAP[giftType] || giftType
|
||||
const n = Math.max(1, Number(count) || 1)
|
||||
const text = n > 1 ? `${base}×${n}` : base
|
||||
// 带倍数时略缩小字号,避免出屏
|
||||
const sizeBoost = n > 1 ? 0.88 : 1
|
||||
return {
|
||||
text,
|
||||
giftType,
|
||||
count: n,
|
||||
birthTime: now,
|
||||
duration: style.duration,
|
||||
fontSize: Math.min(W * style.fontSizeRatio, style.fontSizeMax) * sizeBoost,
|
||||
glowColor: style.glowColor,
|
||||
strokeColor: style.strokeColor,
|
||||
peakScale: style.peakScale,
|
||||
gradient: style.gradient,
|
||||
showHeartOutline: style.showHeartOutline,
|
||||
orb: style.orb,
|
||||
aura: style.aura,
|
||||
wobble: rand(-0.04, 0.04),
|
||||
}
|
||||
}
|
||||
|
||||
function textProgress(t, now) {
|
||||
return Math.min((now - t.birthTime) / t.duration, 1)
|
||||
}
|
||||
|
||||
function textScale(t, now) {
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.18) {
|
||||
const u = p / 0.18
|
||||
return t.peakScale * (1 - (1 - u) ** 4 + Math.sin(u * Math.PI) * 0.18 * (1 - u))
|
||||
}
|
||||
if (p < 0.55) return t.peakScale - (t.peakScale - 1.05) * ((p - 0.18) / 0.37) * 0.45
|
||||
return 1.05 - ((p - 0.55) / 0.45) * 0.28
|
||||
}
|
||||
|
||||
function textOpacity(t, now) {
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.55) return 1
|
||||
if (p < 0.82) return 1 - ((p - 0.55) / 0.27) * 0.45
|
||||
return 0.55 * (1 - ((p - 0.82) / 0.18) ** 2)
|
||||
}
|
||||
|
||||
function textGlow(t, now) {
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.2) return p / 0.2
|
||||
if (p < 0.5) return 1
|
||||
if (p < 0.78) return 1 - ((p - 0.5) / 0.28) * 0.55
|
||||
return 0.45 * (1 - (p - 0.78) / 0.22)
|
||||
}
|
||||
|
||||
function heartOutlineScale(t, now) {
|
||||
if (!t.showHeartOutline) return 0
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.12) return (p / 0.12) * 0.75
|
||||
if (p < 0.45) return 0.75 + ((p - 0.12) / 0.33) * 1.35
|
||||
if (p < 0.75) return 2.1
|
||||
return 2.1 * (1 - (p - 0.75) / 0.25)
|
||||
}
|
||||
|
||||
function heartOutlineOpacity(t, now) {
|
||||
if (!t.showHeartOutline) return 0
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.12) return (p / 0.12) * 0.75
|
||||
if (p < 0.4) return 0.75
|
||||
if (p < 0.7) return 0.75 * (1 - (p - 0.4) / 0.3)
|
||||
return 0
|
||||
}
|
||||
|
||||
function pushParticle(p) {
|
||||
if (particles.length >= MAX_PARTICLES) particles.shift()
|
||||
particles.push(p)
|
||||
}
|
||||
|
||||
function pushRing(r) {
|
||||
if (rings.length >= MAX_RINGS) rings.shift()
|
||||
rings.push(r)
|
||||
}
|
||||
|
||||
function makeParticle(x, y, now, palette, glowPalette, opts = {}) {
|
||||
const angle = opts.angle != null ? opts.angle : rand(0, Math.PI * 2)
|
||||
const speed = opts.speed != null ? opts.speed : rand(50, 180)
|
||||
pushParticle({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed - rand(20, 80),
|
||||
birthTime: now,
|
||||
life: opts.life || rand(700, 1600),
|
||||
color: pick(palette),
|
||||
glow: pick(glowPalette),
|
||||
size: opts.size || rand(2.5, 7),
|
||||
kind: opts.kind || 'dot',
|
||||
gravity: opts.gravity != null ? opts.gravity : 70,
|
||||
rot: rand(0, Math.PI * 2),
|
||||
rotSpeed: rand(-2.5, 2.5),
|
||||
drift: rand(-18, 18),
|
||||
})
|
||||
}
|
||||
|
||||
function spawnShockwave(cx, cy, now, color, count = 2) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
pushRing({
|
||||
x: cx,
|
||||
y: cy,
|
||||
birthTime: now + i * 90,
|
||||
life: 900 + i * 120,
|
||||
maxR: Math.min(W, H) * (0.28 + i * 0.12),
|
||||
color,
|
||||
lineWidth: 3.5 - i * 0.8,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function spawnParticles(giftType, now, wave = 0) {
|
||||
const style = GIFT_STYLE[giftType] || GIFT_STYLE['520']
|
||||
const cx = W / 2
|
||||
const cy = H * 0.46
|
||||
const mode = style.particleMode
|
||||
const palette = style.palette
|
||||
const glowPalette = style.glowPalette
|
||||
const boost = wave > 0 ? 0.75 : 1
|
||||
|
||||
if (mode === 'heartBurst') {
|
||||
const n = Math.floor(42 * boost)
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
makeParticle(cx + rand(-50, 50), cy + rand(-40, 40), now, palette, glowPalette, {
|
||||
speed: rand(80, 220),
|
||||
size: rand(8, 18),
|
||||
kind: 'heart',
|
||||
life: rand(1100, 2000),
|
||||
gravity: 55,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < 24 * boost; i += 1) {
|
||||
makeParticle(cx, cy, now, palette, glowPalette, {
|
||||
speed: rand(100, 260),
|
||||
size: rand(1.5, 4),
|
||||
kind: 'spark',
|
||||
life: rand(400, 900),
|
||||
})
|
||||
}
|
||||
} else if (mode === 'goldenRain') {
|
||||
for (let i = 0; i < 48 * boost; i += 1) {
|
||||
makeParticle(cx + rand(-60, 60), cy + rand(-50, 50), now, palette, glowPalette, {
|
||||
speed: rand(90, 240),
|
||||
size: rand(7, 16),
|
||||
kind: 'heart',
|
||||
life: rand(1200, 2200),
|
||||
gravity: 45,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < 30 * boost; i += 1) {
|
||||
makeParticle(cx + rand(-W * 0.2, W * 0.2), cy - H * 0.08, now, palette, glowPalette, {
|
||||
angle: rand(-Math.PI * 0.7, -Math.PI * 0.3),
|
||||
speed: rand(40, 120),
|
||||
size: rand(2, 5),
|
||||
kind: 'spark',
|
||||
gravity: 90,
|
||||
life: rand(800, 1500),
|
||||
})
|
||||
}
|
||||
} else if (mode === 'dualWave') {
|
||||
for (let side = -1; side <= 1; side += 2) {
|
||||
for (let i = 0; i < 22 * boost; i += 1) {
|
||||
makeParticle(cx + side * W * 0.2 + rand(-30, 30), cy + rand(-50, 50), now, palette, glowPalette, {
|
||||
speed: rand(70, 180),
|
||||
size: rand(6, 14),
|
||||
kind: 'heart',
|
||||
life: rand(1000, 1900),
|
||||
})
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 20 * boost; i += 1) {
|
||||
makeParticle(cx, cy, now, palette, glowPalette, { kind: 'spark', speed: rand(60, 160) })
|
||||
}
|
||||
} else if (mode === 'softPetals') {
|
||||
for (let i = 0; i < 36 * boost; i += 1) {
|
||||
makeParticle(cx + rand(-100, 100), cy + rand(-70, 70), now, palette, glowPalette, {
|
||||
speed: rand(25, 90),
|
||||
size: rand(8, 18),
|
||||
kind: 'petal',
|
||||
life: rand(1400, 2400),
|
||||
gravity: 20,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < 18 * boost; i += 1) {
|
||||
makeParticle(cx, cy, now, palette, glowPalette, {
|
||||
speed: rand(30, 80),
|
||||
size: rand(2, 4),
|
||||
kind: 'spark',
|
||||
gravity: 15,
|
||||
life: rand(1000, 1800),
|
||||
})
|
||||
}
|
||||
} else if (mode === 'ring') {
|
||||
for (let ring = 1; ring <= 4; ring += 1) {
|
||||
const r = 28 + ring * 32
|
||||
const count = 12 + ring * 5
|
||||
for (let i = 0; i < count * boost; i += 1) {
|
||||
const a = (i / count) * Math.PI * 2 + ring * 0.2
|
||||
makeParticle(cx + Math.cos(a) * r, cy + Math.sin(a) * r * 0.72, now, palette, glowPalette, {
|
||||
angle: a + rand(-0.15, 0.15),
|
||||
speed: rand(40, 110),
|
||||
size: rand(3, 7),
|
||||
kind: ring % 2 ? 'dot' : 'spark',
|
||||
life: rand(900, 1600),
|
||||
})
|
||||
}
|
||||
}
|
||||
spawnShockwave(cx, cy, now, style.glowColor, 3)
|
||||
} else if (mode === 'stars') {
|
||||
for (let i = 0; i < 40 * boost; i += 1) {
|
||||
makeParticle(cx + rand(-W * 0.38, W * 0.38), cy + rand(-H * 0.28, H * 0.28), now, palette, glowPalette, {
|
||||
speed: rand(15, 70),
|
||||
size: rand(2, 6),
|
||||
kind: 'star',
|
||||
life: rand(1000, 2200),
|
||||
gravity: 12,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < 16 * boost; i += 1) {
|
||||
makeParticle(cx, cy, now, palette, glowPalette, {
|
||||
speed: rand(60, 150),
|
||||
size: rand(5, 12),
|
||||
kind: 'heart',
|
||||
life: rand(900, 1600),
|
||||
})
|
||||
}
|
||||
} else if (mode === 'ripple') {
|
||||
for (let waveIdx = 0; waveIdx < 4; waveIdx += 1) {
|
||||
const count = 18
|
||||
for (let i = 0; i < count * boost; i += 1) {
|
||||
const a = (i / count) * Math.PI * 2
|
||||
makeParticle(cx, cy, now, palette, glowPalette, {
|
||||
angle: a,
|
||||
speed: 35 + waveIdx * 38 + rand(0, 25),
|
||||
life: 1000 + waveIdx * 180,
|
||||
size: rand(2.5, 5.5),
|
||||
gravity: 8,
|
||||
kind: waveIdx % 2 ? 'spark' : 'dot',
|
||||
})
|
||||
}
|
||||
}
|
||||
spawnShockwave(cx, cy, now, style.glowColor, 3)
|
||||
} else if (mode === 'twin') {
|
||||
for (let side = -1; side <= 1; side += 2) {
|
||||
for (let i = 0; i < 20 * boost; i += 1) {
|
||||
makeParticle(cx + side * W * 0.14 + rand(-28, 28), cy + rand(-40, 40), now, palette, glowPalette, {
|
||||
speed: rand(70, 180),
|
||||
size: rand(7, 15),
|
||||
kind: 'heart',
|
||||
life: rand(1100, 1900),
|
||||
})
|
||||
}
|
||||
}
|
||||
spawnShockwave(cx, cy, now, style.glowColor, 2)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoop() {
|
||||
if (running) return
|
||||
running = true
|
||||
rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function stopIfIdle() {
|
||||
if (texts.length || particles.length || rings.length || flashAlpha > 0.01 || playQueue.length || queueTimer) return
|
||||
running = false
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = 0
|
||||
}
|
||||
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
function clearSecondaryTimers() {
|
||||
secondaryTimers.forEach(clearTimeout)
|
||||
secondaryTimers = []
|
||||
}
|
||||
|
||||
function clearVisuals() {
|
||||
texts = []
|
||||
particles = []
|
||||
rings = []
|
||||
flashAlpha = 0
|
||||
clearSecondaryTimers()
|
||||
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
function normalizePlayItem(item) {
|
||||
if (typeof item === 'string') {
|
||||
return { giftType: item, name: '', count: 1 }
|
||||
}
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const giftType = item.giftType || item.gift_type || item.type
|
||||
if (!giftType || !GIFT_STYLE[giftType]) return null
|
||||
return {
|
||||
giftType,
|
||||
name: String(item.name || '').trim() || '匿名',
|
||||
count: Math.max(1, Number(item.count) || 1),
|
||||
}
|
||||
}
|
||||
|
||||
function playOne(item) {
|
||||
const payload = normalizePlayItem(item)
|
||||
if (!payload) return 0
|
||||
const { giftType, name, count } = payload
|
||||
if (!W || !H) resizeCanvas()
|
||||
clearVisuals()
|
||||
const now = performance.now()
|
||||
const style = GIFT_STYLE[giftType]
|
||||
texts.push(createTextEffect(giftType, now, count))
|
||||
spawnParticles(giftType, now, 0)
|
||||
spawnShockwave(W / 2, H * 0.46, now, style.glowColor, style.showHeartOutline ? 3 : 2)
|
||||
flashAlpha = style.flash
|
||||
flashDecay = style.flash / Math.max(1, style.flashMs / 16)
|
||||
flashColor = style.flashColor
|
||||
if (style.secondaryBurst) {
|
||||
const t = setTimeout(() => {
|
||||
spawnParticles(giftType, performance.now(), 1)
|
||||
ensureLoop()
|
||||
}, 280)
|
||||
secondaryTimers.push(t)
|
||||
}
|
||||
emit('play-start', { giftType, name, count })
|
||||
ensureLoop()
|
||||
return style.duration + 280
|
||||
}
|
||||
|
||||
function scheduleNextFromQueue(delayMs) {
|
||||
clearTimeout(queueTimer)
|
||||
queueTimer = setTimeout(() => {
|
||||
queueTimer = null
|
||||
drainPlayQueue()
|
||||
}, Math.max(80, delayMs))
|
||||
}
|
||||
|
||||
function drainPlayQueue() {
|
||||
if (queueTimer) return
|
||||
if (!playQueue.length) {
|
||||
stopIfIdle()
|
||||
return
|
||||
}
|
||||
const next = playQueue.shift()
|
||||
const wait = playOne(next)
|
||||
if (playQueue.length) scheduleNextFromQueue(wait)
|
||||
}
|
||||
|
||||
/** 入队串行播放,保证同一时间只展示一个礼物特效 */
|
||||
const play = (item) => {
|
||||
const payload = normalizePlayItem(item)
|
||||
if (!payload) return
|
||||
playQueue.push(payload)
|
||||
if (texts.length || particles.length || rings.length || flashAlpha > 0.01) {
|
||||
if (!queueTimer) {
|
||||
const active = texts[0]
|
||||
const remain = active
|
||||
? Math.max(200, active.duration - (performance.now() - active.birthTime) + 200)
|
||||
: 400
|
||||
scheduleNextFromQueue(remain)
|
||||
}
|
||||
return
|
||||
}
|
||||
drainPlayQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按序列错开回放(进场用)——严格一个接一个
|
||||
* @param {Array<string|{giftType:string,name?:string,count?:number}>} items
|
||||
* @param {{ max?: number }} opts
|
||||
*/
|
||||
const playSequence = (items, opts = {}) => {
|
||||
const max = opts.max != null ? opts.max : 10
|
||||
const list = (Array.isArray(items) ? items : [])
|
||||
.map(normalizePlayItem)
|
||||
.filter(Boolean)
|
||||
.slice(0, max)
|
||||
if (!list.length) return
|
||||
clearTimeout(queueTimer)
|
||||
queueTimer = null
|
||||
playQueue = list.slice()
|
||||
drainPlayQueue()
|
||||
}
|
||||
|
||||
function animate(timestamp) {
|
||||
if (!running) return
|
||||
const now = timestamp || performance.now()
|
||||
if (!ctx) {
|
||||
rafId = requestAnimationFrame(animate)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
// rings
|
||||
for (let i = rings.length - 1; i >= 0; i -= 1) {
|
||||
const r = rings[i]
|
||||
const age = now - r.birthTime
|
||||
if (age < 0) continue
|
||||
const p = Math.min(age / r.life, 1)
|
||||
if (p >= 1) {
|
||||
rings.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const radius = r.maxR * (1 - (1 - p) ** 2.2)
|
||||
const alpha = (1 - p) * 0.55
|
||||
ctx.save()
|
||||
ctx.globalAlpha = alpha
|
||||
ctx.strokeStyle = r.color
|
||||
ctx.lineWidth = r.lineWidth * (1 - p * 0.5)
|
||||
ctx.shadowColor = r.color
|
||||
ctx.shadowBlur = 12
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(r.x, r.y, radius, radius * 0.72, 0, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// particles
|
||||
for (let i = particles.length - 1; i >= 0; i -= 1) {
|
||||
const p = particles[i]
|
||||
const progress = Math.min((now - p.birthTime) / p.life, 1)
|
||||
if (progress >= 1) {
|
||||
particles.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const age = (now - p.birthTime) / 1000
|
||||
const x = p.x + p.vx * age + Math.sin(age * 3 + p.rot) * (p.drift || 0) * 0.35
|
||||
const y = p.y + p.vy * age + 0.5 * p.gravity * age * age
|
||||
const opacity = progress < 0.15 ? progress / 0.15 : 1 - ((progress - 0.15) / 0.85) ** 1.4
|
||||
const size = p.size * (progress < 0.2 ? 0.6 + progress * 2 : 1 - progress * 0.45)
|
||||
const rot = p.rot + p.rotSpeed * age
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha = Math.max(0, opacity)
|
||||
ctx.fillStyle = p.color
|
||||
ctx.shadowColor = p.glow || p.color
|
||||
ctx.shadowBlur = p.kind === 'spark' || p.kind === 'star' ? 10 : 6
|
||||
|
||||
if (p.kind === 'heart') {
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate(rot * 0.35)
|
||||
drawHeartPath(ctx, 0, 0, size)
|
||||
ctx.fill()
|
||||
} else if (p.kind === 'petal') {
|
||||
ctx.fillStyle = p.color
|
||||
drawPetal(ctx, x, y, size, rot)
|
||||
} else if (p.kind === 'star') {
|
||||
ctx.beginPath()
|
||||
for (let s = 0; s < 8; s += 1) {
|
||||
const rr = s % 2 === 0 ? size : size * 0.38
|
||||
const a = (s / 8) * Math.PI * 2 - Math.PI / 2 + rot
|
||||
const px = x + Math.cos(a) * rr
|
||||
const py = y + Math.sin(a) * rr
|
||||
if (s === 0) ctx.moveTo(px, py)
|
||||
else ctx.lineTo(px, py)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
} else if (p.kind === 'spark') {
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate(rot)
|
||||
ctx.fillRect(-size * 0.35, -size * 1.4, size * 0.7, size * 2.8)
|
||||
} else {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// milestone texts
|
||||
for (let i = texts.length - 1; i >= 0; i -= 1) {
|
||||
const mt = texts[i]
|
||||
if (textProgress(mt, now) >= 1) {
|
||||
texts.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const cx = W / 2
|
||||
const cy = H * 0.46
|
||||
const scale = textScale(mt, now)
|
||||
const opacity = textOpacity(mt, now)
|
||||
const glowIntensity = textGlow(mt, now)
|
||||
const fontSize = mt.fontSize
|
||||
if (opacity < 0.01) continue
|
||||
const wobble = Math.sin((now - mt.birthTime) / 180) * mt.wobble
|
||||
|
||||
// soft aura behind text
|
||||
if (mt.aura) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = opacity * 0.22 * glowIntensity
|
||||
const aura = ctx.createRadialGradient(cx, cy, 0, cx, cy, fontSize * 1.8 * scale)
|
||||
aura.addColorStop(0, mt.glowColor)
|
||||
aura.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = aura
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, fontSize * 1.8 * scale, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha = opacity
|
||||
ctx.translate(cx, cy)
|
||||
ctx.rotate(wobble)
|
||||
ctx.scale(scale, scale)
|
||||
const glowBlur = fontSize * 0.4 * glowIntensity
|
||||
ctx.shadowColor = mt.glowColor
|
||||
ctx.shadowBlur = glowBlur
|
||||
ctx.strokeStyle = mt.strokeColor
|
||||
ctx.lineWidth = fontSize * 0.065
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.font = `700 ${fontSize}px "Ma Shan Zheng", "Great Vibes", cursive, serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.strokeText(mt.text, 0, 0)
|
||||
const textGrad = ctx.createLinearGradient(0, -fontSize * 0.55, 0, fontSize * 0.55)
|
||||
const g = mt.gradient
|
||||
textGrad.addColorStop(0, g[0])
|
||||
textGrad.addColorStop(0.35, g[1])
|
||||
textGrad.addColorStop(0.7, g[2])
|
||||
textGrad.addColorStop(1, g[3])
|
||||
ctx.fillStyle = textGrad
|
||||
ctx.shadowBlur = glowBlur * 1.5
|
||||
ctx.fillText(mt.text, 0, 0)
|
||||
ctx.shadowBlur = 0
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.fillText(mt.text, -fontSize * 0.018, -fontSize * 0.028)
|
||||
ctx.restore()
|
||||
|
||||
if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) {
|
||||
const outlineScale = heartOutlineScale(mt, now)
|
||||
const outlineOpacity = heartOutlineOpacity(mt, now)
|
||||
const outlineSize = fontSize * 1.7 * outlineScale
|
||||
ctx.save()
|
||||
ctx.globalAlpha = outlineOpacity
|
||||
ctx.translate(cx, cy)
|
||||
ctx.strokeStyle = mt.glowColor
|
||||
ctx.lineWidth = outlineSize * 0.035
|
||||
ctx.shadowColor = mt.glowColor
|
||||
ctx.shadowBlur = outlineSize * 0.28
|
||||
drawHeartPath(ctx, 0, 0, outlineSize)
|
||||
ctx.stroke()
|
||||
ctx.lineWidth = outlineSize * 0.065
|
||||
ctx.shadowBlur = outlineSize * 0.45
|
||||
ctx.globalAlpha = outlineOpacity * 0.35
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
if (mt.orb && opacity > 0.18) {
|
||||
const numOrbs = mt.showHeartOutline ? 16 : 12
|
||||
const orbRadius = fontSize * (1.05 + 0.15 * glowIntensity)
|
||||
const age = (now - mt.birthTime) / 1000
|
||||
for (let j = 0; j < numOrbs; j += 1) {
|
||||
const angle = age * 1.9 + j * ((Math.PI * 2) / numOrbs)
|
||||
const orbX = cx + Math.cos(angle) * orbRadius
|
||||
const orbY = cy + Math.sin(angle) * orbRadius * 0.68
|
||||
const orbSize = fontSize * 0.035 * (1 + Math.sin(age * 3 + j) * 0.45)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = opacity * (0.55 + Math.sin(age * 2.4 + j) * 0.3)
|
||||
ctx.fillStyle = '#fffbe6'
|
||||
ctx.shadowColor = mt.glowColor
|
||||
ctx.shadowBlur = orbSize * 6
|
||||
ctx.beginPath()
|
||||
ctx.arc(orbX, orbY, Math.max(2, orbSize), 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// colored flash on top
|
||||
if (flashAlpha > 0.01) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = Math.min(flashAlpha, 1)
|
||||
ctx.fillStyle = flashColor
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
ctx.restore()
|
||||
flashAlpha = Math.max(0, flashAlpha - flashDecay)
|
||||
}
|
||||
|
||||
if (texts.length || particles.length || rings.length || flashAlpha > 0.01 || playQueue.length || queueTimer) {
|
||||
rafId = requestAnimationFrame(animate)
|
||||
} else {
|
||||
stopIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
const onResize = () => resizeCanvas()
|
||||
|
||||
onMounted(() => {
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(queueTimer)
|
||||
clearSecondaryTimers()
|
||||
queueTimer = null
|
||||
playQueue = []
|
||||
running = false
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
window.removeEventListener('resize', onResize)
|
||||
texts = []
|
||||
particles = []
|
||||
rings = []
|
||||
})
|
||||
|
||||
defineExpose({ play, playSequence })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gift-effect-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 46;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
143
vite-tailwindcss/src/components/GiftFeed.vue
Normal file
143
vite-tailwindcss/src/components/GiftFeed.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="gift-feed" aria-live="polite">
|
||||
<TransitionGroup name="gift-feed-item" tag="div" class="gift-feed-list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="gift-feed-row"
|
||||
>
|
||||
<span class="gift-feed-name">{{ item.name }}</span>
|
||||
<span class="gift-feed-verb">送出</span>
|
||||
<span class="gift-feed-gift calligraphy-strong">{{ item.label }}</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
|
||||
|
||||
const MAX_ITEMS = 5
|
||||
const LIFE_MS = 8000
|
||||
const items = ref([])
|
||||
const timers = new Map()
|
||||
let seq = 0
|
||||
|
||||
const formatLabel = (giftType, count = 1) => {
|
||||
const base = GIFT_LABEL_MAP[giftType] || giftType
|
||||
const n = Math.max(1, Number(count) || 1)
|
||||
return n > 1 ? `${base}×${n}` : base
|
||||
}
|
||||
|
||||
const removeItem = (id) => {
|
||||
const t = timers.get(id)
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
timers.delete(id)
|
||||
}
|
||||
items.value = items.value.filter((it) => it.id !== id)
|
||||
}
|
||||
|
||||
/** 追加一条(与当前特效同步);最新在底部,旧的上移;最多 5 条;8 秒后向左淡出 */
|
||||
const push = (name, giftType, count = 1) => {
|
||||
const n = String(name || '').trim() || '匿名'
|
||||
const type = String(giftType || '').trim()
|
||||
if (!type) return
|
||||
seq += 1
|
||||
const id = `${Date.now()}-${seq}`
|
||||
const next = {
|
||||
id,
|
||||
name: n,
|
||||
giftType: type,
|
||||
count: Math.max(1, Number(count) || 1),
|
||||
label: formatLabel(type, count),
|
||||
}
|
||||
while (items.value.length >= MAX_ITEMS) {
|
||||
removeItem(items.value[0].id)
|
||||
}
|
||||
items.value = [...items.value, next]
|
||||
const timer = setTimeout(() => removeItem(id), LIFE_MS)
|
||||
timers.set(id, timer)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
timers.forEach((t) => clearTimeout(t))
|
||||
timers.clear()
|
||||
})
|
||||
|
||||
defineExpose({ push })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gift-feed {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 48;
|
||||
max-width: min(72vw, 260px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gift-feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gift-feed-row {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(70, 70, 75, 0.28);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.gift-feed-name {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
max-width: 5.5em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gift-feed-verb {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.gift-feed-gift {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.06em;
|
||||
color: #ffe8a8;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.gift-feed-item-enter-active {
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
.gift-feed-item-leave-active {
|
||||
transition: opacity 0.45s ease, transform 0.45s ease;
|
||||
}
|
||||
.gift-feed-item-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
.gift-feed-item-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-28px);
|
||||
}
|
||||
.gift-feed-item-move {
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
</style>
|
||||
38
vite-tailwindcss/src/utils/giftTypes.js
Normal file
38
vite-tailwindcss/src/utils/giftTypes.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/** 礼物类型常量表(与后端 giftTypeWhitelist 一致) */
|
||||
export const GIFT_TYPES = [
|
||||
{ key: '520', label: '520' },
|
||||
{ key: '1314', label: '1314' },
|
||||
{ key: 'forever', label: '长长久久' },
|
||||
{ key: 'bald', label: '白头偕老' },
|
||||
{ key: 'concentric', label: '永结同心' },
|
||||
{ key: 'heaven', label: '天作之合' },
|
||||
{ key: 'moisten', label: '相濡以沫' },
|
||||
{ key: 'match', label: '佳偶天成' },
|
||||
]
|
||||
|
||||
export const GIFT_LABEL_MAP = Object.fromEntries(GIFT_TYPES.map((g) => [g.key, g.label]))
|
||||
|
||||
export function emptyGiftCounts() {
|
||||
return Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续相同「姓名 + 礼物」合并为一条(带 count),用于串行特效与左下角展示
|
||||
* @param {Array<{ gift_type?: string, name?: string }>} recent
|
||||
*/
|
||||
export function aggregateGiftRecent(recent) {
|
||||
const list = Array.isArray(recent) ? recent : []
|
||||
const out = []
|
||||
for (const g of list) {
|
||||
if (!g?.gift_type) continue
|
||||
const name = String(g.name || '').trim() || '匿名'
|
||||
const giftType = g.gift_type
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.giftType === giftType && last.name === name) {
|
||||
last.count += 1
|
||||
} else {
|
||||
out.push({ giftType, name, count: 1 })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
17
vite-tailwindcss/src/utils/guestName.js
Normal file
17
vite-tailwindcss/src/utils/guestName.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const KEY = 'wedding_guest_name_v1'
|
||||
|
||||
export function getGuestName() {
|
||||
try {
|
||||
return String(localStorage.getItem(KEY) || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function setGuestName(name) {
|
||||
const v = String(name || '').trim()
|
||||
if (!v) return
|
||||
try {
|
||||
localStorage.setItem(KEY, v)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user