1. 礼物特效

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

View File

@@ -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"},
}

View File

@@ -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"`

View File

@@ -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" {

View File

@@ -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)
}

View 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>

View 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>

View 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
}

View 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 */ }
}

View File

@@ -76,6 +76,22 @@
</div>
</div>
<div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">礼物统计</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">各礼物收到次数 · 合计 {{ giftStats.total }}</div>
</div>
<a-button size="small" :loading="giftStatsLoading" @click="loadGiftStats">
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
</a-button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<div v-for="g in giftTypeList" :key="g.key" class="stat-card">
<div class="stat-label calligraphy">{{ g.label }}</div>
<div class="stat-value">{{ giftStats.counts[g.key] || 0 }}</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-5">
<div class="chart-card">
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">地区分布</div>
@@ -967,12 +983,13 @@ import { CanvasRenderer } from 'echarts/renderers'
import {
getConfigSection, saveConfigSection, uploadImage, getRsvpList, getUploadConfig, saveUploadConfig,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitList,
getPosterConfig, savePosterConfig,
getPosterConfig, savePosterConfig, getGiftStats,
} from '@/api/wedding'
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { GIFT_TYPES } from '@/utils/giftTypes'
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
@@ -1026,6 +1043,12 @@ const danmakuFilter = ref('pending')
const visitStatsLoading = ref(false)
const visitListLoading = ref(false)
const giftStatsLoading = ref(false)
const giftTypeList = GIFT_TYPES
const giftStats = reactive({
total: 0,
counts: Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0])),
})
const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [], devices: [] })
const visitList = ref([])
const visitPreviewList = ref([])
@@ -1695,6 +1718,23 @@ function renderDeviceChart() {
)
}
async function loadGiftStats() {
giftStatsLoading.value = true
try {
const res = await getGiftStats()
const d = res.data || {}
const counts = d.counts || {}
for (const g of GIFT_TYPES) {
giftStats.counts[g.key] = Number(counts[g.key]) || 0
}
giftStats.total = Number(d.total) || Object.values(giftStats.counts).reduce((a, b) => a + b, 0)
} catch (e) {
message.error(e?.message || '加载礼物统计失败')
} finally {
giftStatsLoading.value = false
}
}
async function loadVisitStats() {
visitStatsLoading.value = true
try {
@@ -1747,7 +1787,7 @@ async function loadVisitList() {
}
async function loadOverview() {
await Promise.all([loadVisitStats(), loadVisitPreview()])
await Promise.all([loadVisitStats(), loadVisitPreview(), loadGiftStats()])
}
function onVisitTableChange(pag) {

View File

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

View File

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

View File

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