diff --git a/hunliji-api/main.go b/hunliji-api/main.go
index 7e1c240..5442d6a 100644
--- a/hunliji-api/main.go
+++ b/hunliji-api/main.go
@@ -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"},
}
diff --git a/hunliji-api/models/models.go b/hunliji-api/models/models.go
index db09459..fcc7cf9 100644
--- a/hunliji-api/models/models.go
+++ b/hunliji-api/models/models.go
@@ -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"`
diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go
index 37c98d8..313d3ef 100644
--- a/hunliji-api/router/routers.go
+++ b/hunliji-api/router/routers.go
@@ -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" {
diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js
index ec8d577..c2adf6f 100644
--- a/vite-tailwindcss/src/api/wedding.js
+++ b/vite-tailwindcss/src/api/wedding.js
@@ -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)
}
diff --git a/vite-tailwindcss/src/components/GiftEffect.vue b/vite-tailwindcss/src/components/GiftEffect.vue
new file mode 100644
index 0000000..0d7acdc
--- /dev/null
+++ b/vite-tailwindcss/src/components/GiftEffect.vue
@@ -0,0 +1,854 @@
+
+
+
+
+
+
+
diff --git a/vite-tailwindcss/src/components/GiftFeed.vue b/vite-tailwindcss/src/components/GiftFeed.vue
new file mode 100644
index 0000000..9caa7ec
--- /dev/null
+++ b/vite-tailwindcss/src/components/GiftFeed.vue
@@ -0,0 +1,143 @@
+
+