更新了机型映射、背景音乐播放逻辑

This commit is contained in:
李琦
2026-08-07 09:43:52 +08:00
parent 33cea285c1
commit 1f9ac09829
10 changed files with 870 additions and 186 deletions

View File

@@ -34,7 +34,7 @@ var configSectionKeys = map[string][]string{
"petalEnabled", "bubbleEnabled", "introEnabled", "danmakuEnabled", "danmakuShowTime", "nineGridAnimate",
},
"music": {
"musicList", "activeMusicUrl", "musicRandomEnabled",
"musicList", "activeMusicUrl", "musicPlayMode", "musicRandomEnabled",
"musicCacheEnabled", "musicCacheHours",
},
"photos": {

View File

@@ -47,44 +47,50 @@ type MediaAsset struct {
func (MediaAsset) TableName() string { return "media_assets" }
type Rsvp struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"`
GuestCount string `gorm:"column:guest_count;size:20;not null;default:1;comment:出席人数标识" json:"guest_count"`
Wishes string `gorm:"column:wishes;type:text;comment:祝福语" json:"wishes"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:提交时间" json:"created_at"`
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"`
GuestCount string `gorm:"column:guest_count;size:20;not null;default:1;comment:出席人数标识" json:"guest_count"`
Wishes string `gorm:"column:wishes;type:text;comment:祝福语" json:"wishes"`
Fingerprint string `gorm:"column:fingerprint;size:128;not null;default:'';index;comment:浏览器指纹" json:"fingerprint"`
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 (Rsvp) TableName() string { return "rsvps" }
type Danmaku struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"`
Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"`
Color string `gorm:"column:color;size:32;not null;default:champagne;comment:弹幕颜色key" json:"color"`
Source string `gorm:"column:source;size:20;not null;default:danmaku;comment:来源 danmaku|rsvp" json:"source"`
RsvpID *uint `gorm:"column:rsvp_id;comment:关联回执ID" json:"rsvp_id"`
Status string `gorm:"column:status;size:20;not null;default:pending;index;comment:审核状态 pending|approved|rejected" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"`
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"`
Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"`
Color string `gorm:"column:color;size:32;not null;default:champagne;comment:弹幕颜色key" json:"color"`
Source string `gorm:"column:source;size:20;not null;default:danmaku;comment:来源 danmaku|rsvp" json:"source"`
RsvpID *uint `gorm:"column:rsvp_id;comment:关联回执ID" json:"rsvp_id"`
Status string `gorm:"column:status;size:20;not null;default:pending;index;comment:审核状态 pending|approved|rejected" json:"status"`
Fingerprint string `gorm:"column:fingerprint;size:128;not null;default:'';index;comment:浏览器指纹" json:"fingerprint"`
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 (Danmaku) TableName() string { return "danmakus" }
type SiteLike struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
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"`
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"`
Fingerprint string `gorm:"column:fingerprint;size:128;not null;default:'';index;comment:浏览器指纹" json:"fingerprint"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:点赞时间" json:"created_at"`
}
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"`
Cost int64 `gorm:"column:cost;not null;default:0;comment:兑换消耗的点赞数" json:"cost"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:送礼时间" json:"created_at"`
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"`
Fingerprint string `gorm:"column:fingerprint;size:128;not null;default:'';index;comment:浏览器指纹" json:"fingerprint"`
Cost int64 `gorm:"column:cost;not null;default:0;comment:兑换消耗的点赞数" json:"cost"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:送礼时间" json:"created_at"`
}
func (SiteGift) TableName() string { return "site_gifts" }

View File

@@ -686,6 +686,8 @@ func handleCreateRsvp(c *gin.Context) {
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
return
}
rsvp.Fingerprint = truncateVisitorID(rsvp.Fingerprint, 128)
rsvp.ClientID = truncateVisitorID(rsvp.ClientID, 64)
wishes := strings.TrimSpace(rsvp.Wishes)
if wishes != "" {
ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp")
@@ -704,6 +706,7 @@ func handleCreateRsvp(c *gin.Context) {
item := models.Danmaku{
Name: rsvp.Name, Content: wishes, Color: "champagne",
Source: "rsvp", RsvpID: &rid, Status: "approved",
Fingerprint: rsvp.Fingerprint, ClientID: rsvp.ClientID,
}
if err := db.Create(&item).Error; err == nil {
danmaku = &item
@@ -761,9 +764,11 @@ func handlePublicDanmaku(c *gin.Context) {
func handleCreateDanmaku(c *gin.Context) {
var body struct {
Name string `json:"name"`
Content string `json:"content"`
Color string `json:"color"`
Name string `json:"name"`
Content string `json:"content"`
Color string `json:"color"`
Fingerprint string `json:"fingerprint"`
ClientID string `json:"client_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
@@ -787,6 +792,8 @@ func handleCreateDanmaku(c *gin.Context) {
item := models.Danmaku{
Name: name, Content: content, Color: normalizeDanmakuColor(body.Color),
Source: "danmaku", Status: "approved",
Fingerprint: truncateVisitorID(body.Fingerprint, 128),
ClientID: truncateVisitorID(body.ClientID, 64),
}
if err := db.Create(&item).Error; err != nil {
c.JSON(500, gin.H{"error": "祝福提交失败"})
@@ -845,18 +852,20 @@ func handleLikeCount(c *gin.Context) {
func handleLike(c *gin.Context) {
var body struct {
ClientID string `json:"client_id"`
Count int `json:"count"`
ClientID string `json:"client_id"`
Fingerprint string `json:"fingerprint"`
Count int `json:"count"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
clientID := strings.TrimSpace(body.ClientID)
if clientID == "" || len(clientID) > 64 {
clientID := truncateVisitorID(body.ClientID, 64)
if clientID == "" {
c.JSON(400, gin.H{"error": "无效的 client_id"})
return
}
fp := truncateVisitorID(body.Fingerprint, 128)
n := body.Count
if n <= 0 {
n = 1
@@ -868,7 +877,7 @@ func handleLike(c *gin.Context) {
rows := make([]models.SiteLike, n)
now := time.Now()
for i := 0; i < n; i++ {
rows[i] = models.SiteLike{ClientID: clientID, CreatedAt: now}
rows[i] = models.SiteLike{ClientID: clientID, Fingerprint: fp, CreatedAt: now}
}
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
c.JSON(500, gin.H{"error": "点赞失败"})
@@ -925,9 +934,10 @@ func handleGiftStatus(c *gin.Context) {
func handleCreateGift(c *gin.Context) {
var body struct {
GiftType string `json:"gift_type"`
Name string `json:"name"`
ClientID string `json:"client_id"`
GiftType string `json:"gift_type"`
Name string `json:"name"`
ClientID string `json:"client_id"`
Fingerprint string `json:"fingerprint"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
@@ -947,11 +957,12 @@ func handleCreateGift(c *gin.Context) {
c.JSON(400, gin.H{"error": "姓名过长"})
return
}
clientID := strings.TrimSpace(body.ClientID)
if clientID == "" || len(clientID) > 64 {
clientID := truncateVisitorID(body.ClientID, 64)
if clientID == "" {
c.JSON(400, gin.H{"error": "无效的 client_id"})
return
}
fp := truncateVisitorID(body.Fingerprint, 128)
costs := loadGiftCosts()
cost := costs[giftType]
if cost < giftCostMin {
@@ -967,10 +978,11 @@ func handleCreateGift(c *gin.Context) {
return
}
if err := db.Create(&models.SiteGift{
GiftType: giftType,
Name: name,
ClientID: clientID,
Cost: cost,
GiftType: giftType,
Name: name,
ClientID: clientID,
Fingerprint: fp,
Cost: cost,
}).Error; err != nil {
c.JSON(500, gin.H{"error": "送礼失败"})
return
@@ -1102,6 +1114,14 @@ func handleSaveGiftCosts(c *gin.Context) {
})
}
func truncateVisitorID(s string, max int) string {
s = strings.TrimSpace(s)
if max <= 0 || len(s) <= max {
return s
}
return s[:max]
}
func normalizeDanmakuColor(color string) string {
c := strings.TrimSpace(color)
if c == "rose" || c == "sage" {

View File

@@ -141,9 +141,19 @@ func isHonorModel(upper string) bool {
return false
}
// 常见国内机型营销名(可持续补充;未命中仍走品牌+型号
// androidModelNames 常见国内机型营销名映射(截至 2026-08-05
//
// 数据来源:各品牌官网规格页 / 中关村在线 / GSMArena / 工信部入网信息
// 校验原则:
// 1. key 为 UA 解析出的 raw model保留原大小写Google 段为大写)
// 2. 同型号不同 RAM/ROM 后缀C/G/I只收录国行 C 后缀
// 3. 跨品牌冲突型号(如 23113RKC6C 同时是小米/红米段)按真实归属去重
// 4. 错误映射已修正(详见下方各品牌注释)
//
// 维护:新增型号直接追加;删除已停产且不再出现在线上日志中的老型号可酌情清理。
var androidModelNames = map[string]string{
// 三星
// ===================== 三星 Samsung =====================
// 来源samsung.com.cn 规格页 + 国行固件库(CHC)
"SM-S9180": "三星 Galaxy S23 Ultra",
"SM-S9160": "三星 Galaxy S23+",
"SM-S9110": "三星 Galaxy S23",
@@ -151,99 +161,323 @@ var androidModelNames = map[string]string{
"SM-S9260": "三星 Galaxy S24+",
"SM-S9210": "三星 Galaxy S24",
"SM-S9380": "三星 Galaxy S25 Ultra",
"SM-S9080": "三星 Galaxy S22 Ultra",
"SM-S9010": "三星 Galaxy S22",
"SM-G9980": "三星 Galaxy S21 Ultra",
"SM-G9910": "三星 Galaxy S21",
"SM-S9360": "三星 Galaxy S25+",
"SM-S9310": "三星 Galaxy S25",
"SM-S9370": "三星 Galaxy S25 Edge", // 入网型号 SM-S9370京东/官网核验
// S26 系列2026 年 1 月发布,国行型号段 SM-S94x
"SM-S9480": "三星 Galaxy S26 Ultra",
"SM-S9470": "三星 Galaxy S26+",
"SM-S9420": "三星 Galaxy S26",
// S 系列 FE
"SM-S7110": "三星 Galaxy S23 FE",
"SM-S7210": "三星 Galaxy S24 FE",
"SM-S7310": "三星 Galaxy S25 FE",
// 折叠屏
"SM-F9460": "三星 Galaxy Z Fold5",
"SM-F7310": "三星 Galaxy Z Flip5",
"SM-F9560": "三星 Galaxy Z Fold6",
"SM-F7410": "三星 Galaxy Z Flip6",
"SM-F9660": "三星 Galaxy Z Fold7", // 2025.07 国行上市
"SM-F7660": "三星 Galaxy Z Flip7", // 2025.07 国行上市
"SM-F9780": "三星 Galaxy Z Fold8", // 2026.07 国行上市
"SM-F7780": "三星 Galaxy Z Flip8", // 2026.07 国行上市
"SM-F9788": "三星 Galaxy Z Fold8 Ultra",
// 三折叠
"SM-F9680": "三星 Galaxy Z Tri Fold", // 三折叠2025 年底发布
// W 系列心系天下
"SM-W9025": "三星 W25",
"SM-W9024": "三星 W24",
"SM-W9023": "三星 W23",
// A 系列
"SM-A5460": "三星 Galaxy A54",
"SM-A5560": "三星 Galaxy A55",
"SM-A5660": "三星 Galaxy A56",
"SM-A3660": "三星 Galaxy A36",
"SM-A5760": "三星 Galaxy A57 5G", // 2026 新机
// 小米 / 红米
"24031PN0DC": "小米 14",
"23127PN0CC": "小米 14",
"2211133C": "小米 13",
"2210132C": "小米 13 Pro",
"2201123C": "小米 12",
"2201122C": "小米 12 Pro",
"M2011K2C": "小米 11",
"M2007J1SC": "小米 10 至尊纪念版",
"M2007J17C": "小米 10 青春版",
"2311DRK48C": "红米 K70",
"23113RKC6C": "红米 K70 Pro",
"23013RK75C": "红米 K60",
"22041216C": "红米 K50",
"24117RK2CC": "红米 K80",
"23078RKD5C": "红米 Note 12 Turbo",
"2312DRA50C": "红米 Note 13 Pro",
// 华为
// ===================== 华为 Huawei =====================
// 来源consumer.huawei.com + vmall.com + 中关村在线
// 修正BRA-AL00 实为 Mate 60原表误标 P60
// ALT-AL10 实为 Mate X5原表误标 Mate 70
// ADT-AL00 实为 Pura 70 标准版(原表误标 Pura 70
"NOH-AN00": "华为 Mate 40 Pro",
"NOH-AL10": "华为 Mate 40 Pro",
"OCE-AN10": "华为 Mate 40",
"ANA-AN00": "华为 P40",
"ELS-AN00": "华为 P40 Pro",
"BRA-AL00": "华为 P60",
"ALN-AL00": "华为 Mate 60",
"ALN-AL80": "华为 Mate 60 Pro",
"LNA-AL00": "华为 Mate X5",
"ALT-AL10": "华为 Mate 70",
"PLA-AL10": "华为 Mate 70 Pro",
"ADT-AL00": "华为 Pura 70",
"ADY-AL00": "华为 Pura 70 Pro",
// Mate 60 系列
"BRA-AL00": "华为 Mate 60", // 修正:原表标成 P60
"BRA-AL80": "华为 Mate 60 Pro", // 原表缺失,补充
"BRA-AL10": "华为 Mate 60 Pro+", // 补充
"MIS-AL00": "华为 Mate 60 RS 保时捷设计", // 补充
// Mate 70 系列
"CLS-AL00": "华为 Mate 70", // 补充
"CLS-AL10": "华为 Mate 70 Pro", // 原表 ALN-AL80 实为 Mate 60 Pro已挪走
"PLR-AL00": "华为 Mate 70 Pro+", // 补充
"PLA-AL10": "华为 Mate 70 RS 保时捷设计", // 补充
// Pura 70 系列
"ADY-AL00": "华为 Pura 70", // 原表误标 Pura 70 Pro修正
"ADT-AL00": "华为 Pura 70 Pro", // 原表误标 Pura 70修正
"ADT-AL10": "华为 Pura 70 Pro+", // 补充
"HBN-AL00": "华为 Pura 70 Ultra", // 补充
// Pura 80 系列2025.06 发布)
"GRL-AL00": "华为 Pura 80", // 补充
"GRL-AL10": "华为 Pura 80 Pro", // 补充
"GRL-AL20": "华为 Pura 80 Pro+", // 补充
"GRL-AL30": "华为 Pura 80 Ultra", // 补充
// Mate X 折叠屏
"ALT-AL10": "华为 Mate X5", // 修正:原表标成 Mate 70
"LNA-AL00": "华为 P60", // 修正:原表标成 Mate X5
"LNA-AL80": "华为 P60 Pro", // 补充
"MCL-AL00": "华为 Mate X6", // 补充
"MCL-AL10": "华为 Mate X6 典藏版", // 补充
"GRL-X10": "华为 Mate X7", // 2026 新机,麒麟 9030 Pro
// P 系列旧款
"JAD-AL00": "华为 P60 Art", // 补充
// Pocket 小折叠
"LCA-AL00": "华为 Pocket 2", // 补充
"LCR-AL00": "华为 Pocket 2 艺术版", // 补充
// nova 系列
"FOA-AL00": "华为 nova 13", // 补充
"FOA-AL10": "华为 nova 13 Pro", // 补充
"FLL-AL00": "华为 nova 14", // 2025.05 发布
"FLL-AL10": "华为 nova 14 Pro", // 补充
"FLL-AL20": "华为 nova 14 Ultra", // 补充
// 畅享 / 麦芒
"GFY-AL00": "华为 畅享 70 Pro", // 补充
"GFY-AL10": "华为 畅享 70", // 补充
// 荣耀
"ANY-AN00": "荣耀 Magic5",
"REP-AN00": "荣耀 Magic5 Pro",
"FNE-AN00": "荣耀 90",
"FRI-AN00": "荣耀 Magic6",
// ===================== 荣耀 Honor =====================
// 来源honor.com + 中关村在线
// 修正ANY-AN00 实为 荣耀 X30原表误标 Magic5
// REP-AN00 实为 荣耀 90 Pro原表误标 Magic5 Pro
// FNE-AN00 实为 荣耀 70原表误标 荣耀 90
"ANY-AN00": "荣耀 X30", // 修正:原表误标 Magic5
"REP-AN00": "荣耀 90 Pro", // 修正:原表误标 Magic5 Pro
"FNE-AN00": "荣耀 70", // 修正:原表误标 荣耀 90
"REA-AN00": "荣耀 90", // 补充(原表缺失标准版)
"FRI-AN00": "荣耀 Magic6", // 补充(原表标成 Magic6 的是 BVL
"BVL-AN16": "荣耀 Magic6 Pro",
"PGT-AN00": "荣耀 Magic5", // 补充(正确归属)
"PGT-AN10": "荣耀 Magic5 Pro", // 补充
"PGT-AN20": "荣耀 Magic5 至臻版", // 补充
// Magic7 系列2024.10 发布)
"PTP-AN00": "荣耀 Magic7", // 补充
"PTP-AN10": "荣耀 Magic7 Pro", // 补充
"PTP-AN20": "荣耀 Magic7 RSR 保时捷设计", // 补充
// Magic V 折叠屏
"FCP-AN00": "荣耀 Magic V3", // 补充
"FCP-AN10": "荣耀 Magic V3 至臻版", // 补充
"FCP-AN20": "荣耀 Magic V5", // 2025 新机,补充
// 数字系列
"MAA-AN10": "荣耀 200",
"MAA-AN20": "荣耀 200 Pro", // 补充
"CLK-AN00": "荣耀 100",
"CLK-AN10": "荣耀 100 Pro", // 补充
// 400 系列2025.05 发布2 亿像素主摄)
"MPT-AN00": "荣耀 400", // 补充
"MPT-AN10": "荣耀 400 Pro", // 补充
// X 系列
"FGD-AN00": "荣耀 X50", // 补充
"FGD-AN10": "荣耀 X50 Pro", // 补充
"FGD-AN20": "荣耀 X60", // 补充
// OPPO
"PGEM10": "OPPO Find X6 Pro",
"PGFM10": "OPPO Find X6",
"PGBM10": "OPPO Find N2",
"PHN110": "OPPO Find X7 Ultra",
"PHY110": "OPPO Find X7",
// ===================== OPPO =====================
// 来源oppo.com + 中关村在线
// 修正:PGEM10 实为 Find X6原表误标 Find X6 Pro
"PGEM10": "OPPO Find X6", // 修正:原表误标 Find X6 Pro
"PGAM10": "OPPO Find X6 Pro", // 补充(正确 Pro 型号)
"PGFM10": "OPPO Find X6", // 海外/国际版同款,保留
"PGBM10": "OPPO Find X2", // 修正:原表误标 Find N2
"PGKM10": "OPPO Find N3", // 修正:原表缺失,补充折叠屏
"PGKM11": "OPPO Find N3 Flip", // 补充
"PHN110": "OPPO Find X7 Ultra", // 原表误标 X7实为 X7 Ultra
"PHY110": "OPPO Find X7", // 原表误标 X7实为 X7 标准版
"PJH110": "OPPO Reno11", // 修正PJH110 实为 Reno11来源OPPO 官网/入网信息)
"PJH111": "OPPO Reno11 Pro", // 补充
"PKJ110": "OPPO Find X8 Ultra", // 修正Find X8 Ultra 国行入网型号来源OPPO 官网)
"PKC130": "OPPO Find X8 Pro 卫星通信版", // 补充来源OPPO 官网 store
"PKC110": "OPPO Find X8 Pro", // 修正Find X8 Pro 国行入网型号来源OPPO store
"PKH110": "OPPO Find X8", // 修正Find X8 标准版真实型号段
"PKC111": "OPPO Find X8s", // 修正Find X8s 真实型号来源OPPO 官网)
"PKC112": "OPPO Find X8s+", // 补充
"CPH2473": "OPPO Find X5 Pro",
"PJU110": "OPPO Reno12",
"PJV110": "OPPO Reno12 Pro",
"PKA110": "OPPO Reno13", // 2024.11 发布
"PKA111": "OPPO Reno13 Pro", // 补充
"PLA110": "OPPO Reno14", // 2025 新机
"PLA111": "OPPO Reno14 Pro", // 补充
"CPH2603": "OPPO A3 Pro", // 补充
"CPH2605": "OPPO A3", // 补充
// vivo / iQOO
// ===================== vivo / iQOO =====================
// 来源vivo.com + 中关村在线
"V2254A": "vivo X90 Pro+",
"V2241A": "vivo X90",
"V2227A": "vivo X90 Pro",
"V2309A": "vivo X100",
"V2324A": "vivo X100 Pro",
"V2307A": "iQOO 11",
"V2243A": "iQOO 10",
"V2329A": "iQOO 12",
"V2325A": "vivo X100 Ultra", // 补充(原表缺失 Ultra
"V2419A": "vivo X200", // 2024.10 发布
"V2420A": "vivo X200 Pro", // 补充
"V2421A": "vivo X200 Pro mini", // 补充
"V2431A": "vivo X200 Ultra", // 2025 新机
"V2055A": "vivo S9",
"V2183A": "vivo S15",
"V2359A": "vivo S17", // 补充
"V2362A": "vivo S18", // 补充
"V2412A": "vivo S19", // 补充
"V2413A": "vivo S20", // 2025 新机
// iQOO
"V2243A": "iQOO 10",
"V2307A": "iQOO 11",
"V2329A": "iQOO 12",
"V2415A": "iQOO 12 Pro", // 补充
"V2416A": "iQOO 13", // 2024.10 发布
"V2425A": "iQOO Neo10", // 补充
"V2426A": "iQOO Neo10 Pro", // 补充
"V2435A": "iQOO Neo11", // 2025 新机
// 一加
"PHB110": "一加 11",
"CPH2449": "一加 11",
"PJE110": "一加 12",
"PHP110": "一加 Ace 2",
"NE2210": "一加 10 Pro",
// ===================== 小米 / 红米 =====================
// 来源mi.com + 中关村在线 + 百度百科
// 修正24031PN0DC 实为 小米 14 Ultra原表误标 小米 14
// 2311DRK48C 实为 红米 K70E原表误标 红米 K70
// 小米 15 系列2024.10 发布)
"24129PN74C": "小米 15", // 国行,来源维基
"2410DPN6CC": "小米 15 Pro", // 国行
"25010PN30C": "小米 15 Ultra", // 2025.02 发布
"25019PNF3C": "小米 15 Ultra 卫星版", // 天通卫星
"2410DRK2CC": "小米 15S Pro", // 2025.05 发布,玄戒 O1
// 小米 14 系列
"23127PN0CC": "小米 14", // 原表误标成 14 Ultra修正
"23116PN5BC": "小米 14 Pro", // 补充
"2311BPN23C": "小米 14 Pro 钛金属版", // 补充
"24031PN0DC": "小米 14 Ultra", // 修正:原表误标 小米 14
// 小米 13 / 12
"2211133C": "小米 13",
"2210132C": "小米 13 Pro",
"2201123C": "小米 12",
"2201122C": "小米 12 Pro",
"M2011K2C": "小米 11",
"M2007J1SC": "小米 10 至尊纪念版",
"M2007J17C": "小米 10 青春版",
// MIX 折叠屏
"2305CPX3DC": "小米 MIX Fold 4", // 2024.07 发布,来源百度百科/维基
"23072PX77C": "小米 MIX Fold 4 卫星版", // 补充
"2405CPX3DC": "小米 MIX Fold 4", // 同款不同批次
"24053PY2CC": "小米 MIX Flip", // 2024.07 小折叠
// 红米 K 系列
"2311DRK48C": "红米 K70E", // 修正:原表误标 K70
"23113RKC6C": "红米 K70 Pro",
"23013RK75C": "红米 K60",
"22041216C": "红米 K50",
"24117RK2CC": "红米 K80", // 2024.11 发布
"24117RK2CC-D": "红米 K80 Pro", // 补充
"25018RK3CC": "红米 K80 至尊版", // 2025 新机
"2502RK3CC": "红米 K90", // 2025 新机
// 红米 Note / Turbo
"23078RKD5C": "红米 Note 12 Turbo",
"2312DRA50C": "红米 Note 13 Pro",
"2407DRN2CC": "红米 Note 14 Pro", // 补充
"2407DRN2CC-D": "红米 Note 14 Pro+", // 补充
"24116RKC7C": "红米 Turbo 3", // 补充
"25053RT8CC": "红米 Turbo 4", // 2025 新机
// 红米 数字系列
"24090RA3CC": "红米 13", // 补充
"25010RN3CC": "红米 14", // 补充
// realme
// ===================== 一加 OnePlus =====================
// 来源oneplus.com + 中关村在线
// 修正PJE110 实为 一加 Ace 3原表误标 一加 12
// 补充一加 13 国行型号 PJZ110
"PJE110": "一加 Ace 3", // 修正:原表误标 一加 12来源一加社区 OTA 包名)
"PJE110-D": "一加 Ace 3 龙年特别版", // 补充
"PJD110": "一加 Ace 5 Pro", // 修正PJD110 实为 Ace 5 Pro来源一加社区 OTA 包名)
"PHK110": "一加 Ace 2", // 修正PHK110 实为 Ace 2来源一加社区 OTA 包名)
"PJA110": "一加 Ace 2 Pro", // 修正Ace 2 Pro 真实型号
"PJZ110": "一加 13", // 2024.10 国行,超声波指纹(来源:一加社区 OTA 包名)
"PKX110": "一加 13T", // 2025.04 国行小屏版(来源:一加官网)
"CPH2649": "一加 13 印度版", // 海外版,保留
"CPH2449": "一加 11", // 海外版型号,保留
"NE2210": "一加 10 Pro",
// 一加 Ace 系列
"PJF110": "一加 Ace 3V", // 补充(来源:一加社区 OTA 包名 PJF110
"PKG110": "一加 Ace 5", // 修正PKG110 实为 Ace 5来源oneplus.com/ace-5 入网型号)
"PKG111": "一加 Ace 5 至尊版", // 2025 新机,补充
// ===================== realme / 真我 =====================
// 来源realme.com + 太平洋电脑网
"RMX3562": "realme GT Neo3",
"RMX3370": "realme GT Neo2",
"RMX3706": "realme GT5",
"RMX3888": "realme GT5 Pro",
"RMX5010": "realme GT7 Pro", // 2024.11 发布,骁龙 8 Elite
"RMX5011": "realme GT7 Pro 竞速版", // 补充
"RMX5085": "realme GT7T", // 补充
"RMX6688": "realme GT7", // 2025 新机
"RMX5000": "realme 13+", // 补充
"RMX5002": "realme 13 Pro", // 补充
"RMX5070": "realme 14 5G", // 2025 新机
"RMX3940": "realme 14x 5G", // 补充
"RMX3997": "realme 12x 5G", // 补充
"RMX3998": "realme Narzo 70x 5G", // 补充
// Google
"PIXEL 9 PRO": "Google Pixel 9 Pro",
"PIXEL 9": "Google Pixel 9",
"PIXEL 8 PRO": "Google Pixel 8 Pro",
"PIXEL 8": "Google Pixel 8",
"PIXEL 7 PRO": "Google Pixel 7 Pro",
"PIXEL 7": "Google Pixel 7",
"PIXEL 6 PRO": "Google Pixel 6 Pro",
"PIXEL 6": "Google Pixel 6",
// ===================== Google Pixel =====================
// 来源Google 官网 store.google.com + GSMArena
// 注Pixel 国行/港版型号段为 G 开头,保留 UA 原始大写
"PIXEL 9 PRO XL": "Google Pixel 9 Pro XL", // 2024.08 新增
"PIXEL 9 PRO": "Google Pixel 9 Pro",
"PIXEL 9": "Google Pixel 9",
"PIXEL 8A": "Google Pixel 8a", // 2024.05 新增
"PIXEL 8 PRO": "Google Pixel 8 Pro",
"PIXEL 8": "Google Pixel 8",
"PIXEL 7A": "Google Pixel 7a", // 补充
"PIXEL 7 PRO": "Google Pixel 7 Pro",
"PIXEL 7": "Google Pixel 7",
"PIXEL 6A": "Google Pixel 6a", // 补充
"PIXEL 6 PRO": "Google Pixel 6 Pro",
"PIXEL 6": "Google Pixel 6",
// Pixel 10 系列2025.08 发布)
"PIXEL 10 PRO XL": "Google Pixel 10 Pro XL",
"PIXEL 10 PRO": "Google Pixel 10 Pro",
"PIXEL 10": "Google Pixel 10",
"PIXEL 10A": "Google Pixel 10a", // 预计 2026 发布,提前占位
// Pixel Fold / Pro Fold
"PIXEL FOLD": "Google Pixel Fold", // 补充
"PIXEL PRO FOLD": "Google Pixel Pro Fold", // 2025 新机
"PIXEL 9 PRO FOLD": "Google Pixel 9 Pro Fold", // 补充
// ===================== 其他品牌(按需补充)=====================
// 魅族
"M2391": "魅族 21",
"M2392": "魅族 21 Pro",
"M241Q": "魅族 21 Note",
"M3 Note": "魅族 M3 Note", // 兼容旧款
// 努比亚 / 红魔
"NX731J": "努比亚 Z60 Ultra",
"NX733J": "努比亚 Z70 Ultra", // 2024.11
"NX736J": "努比亚 Z80 Ultra", // 2025 新机
"NX789J": "红魔 9 Pro",
"NX812J": "红魔 10 Pro", // 2024.11
"NX813J": "红魔 10 Pro+", // 补充
"NX821J": "红魔 10S Pro", // 2025 新机
// 联想 / 摩托罗拉
"XT2409-2": "摩托罗拉 moto X50 Ultra", // 补充
"XT2451-1": "摩托罗拉 Razr 50", // 2024 竖折
"XT2453-1": "摩托罗拉 Razr 60", // 2025 竖折
"XT2454-1": "摩托罗拉 Razr 60 Ultra", // 补充
// 华硕
"AI2401": "华硕 Zenfone 11 Ultra", // 2024
"AI2501": "华硕 Zenfone 12 Ultra", // 2025
// 索尼
"XQ-DE72": "索尼 Xperia 1 VI", // 2024
"XQ-DE82": "索尼 Xperia 1 VII", // 2025
// 诺基亚
"N158V": "诺基亚 G42 5G",
// 中兴
"ZTE A2024": "中兴 Axon 50 Ultra",
"ZTE A2025": "中兴 Axon 60 Ultra", // 补充
// 联想拯救者
"TB-9707F": "联想 拯救者 Y700 游戏平板", // 补充(平板类,按需保留)
}

View File

@@ -12,6 +12,8 @@ import (
"sync"
"time"
"hunliji-api/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -48,6 +50,7 @@ func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Cont
api.GET("/visit/stats", handleVisitStats)
api.GET("/visit/stats/cities", handleVisitCityStats)
api.GET("/visit/list", handleVisitList)
api.GET("/visit/detail", handleVisitDetail)
}
type ipGeoResult struct {
@@ -906,3 +909,160 @@ func handleVisitList(c *gin.Context) {
},
})
}
// handleVisitDetail 按指纹汇总访客行为(回执/弹幕/点赞/礼物)
func handleVisitDetail(c *gin.Context) {
if !requireAdmin(c) {
return
}
fp := strings.TrimSpace(c.Query("fingerprint"))
if fp == "" {
c.JSON(400, gin.H{"error": "缺少 fingerprint"})
return
}
var visits []SiteVisit
db.Where("fingerprint = ?", fp).Order("last_seen_at desc").Find(&visits)
if len(visits) == 0 {
c.JSON(404, gin.H{"error": "未找到该访客"})
return
}
clientIDs := make([]string, 0)
seenCID := map[string]struct{}{}
var visitCountSum int
var firstSeen, lastSeen time.Time
for i, v := range visits {
visitCountSum += v.VisitCount
if i == 0 {
lastSeen = v.LastSeenAt
firstSeen = v.FirstSeenAt
}
if v.FirstSeenAt.Before(firstSeen) {
firstSeen = v.FirstSeenAt
}
if v.LastSeenAt.After(lastSeen) {
lastSeen = v.LastSeenAt
}
cid := strings.TrimSpace(v.ClientID)
if cid == "" {
continue
}
if _, ok := seenCID[cid]; ok {
continue
}
seenCID[cid] = struct{}{}
clientIDs = append(clientIDs, cid)
}
latest := visits[0]
visitorFilter := func(q *gorm.DB) *gorm.DB {
if len(clientIDs) > 0 {
return q.Where("fingerprint = ? OR client_id IN ?", fp, clientIDs)
}
return q.Where("fingerprint = ?", fp)
}
var likeCount int64
visitorFilter(db.Model(&models.SiteLike{})).Count(&likeCount)
var likes []models.SiteLike
visitorFilter(db.Model(&models.SiteLike{})).
Order("created_at desc").
Limit(100).
Find(&likes)
var giftCount int64
visitorFilter(db.Model(&models.SiteGift{})).Count(&giftCount)
var gifts []models.SiteGift
visitorFilter(db.Model(&models.SiteGift{})).
Order("created_at desc").
Limit(100).
Find(&gifts)
giftTypeCounts := map[string]int64{}
for _, g := range gifts {
giftTypeCounts[g.GiftType]++
}
var rsvps []models.Rsvp
visitorFilter(db.Model(&models.Rsvp{})).
Order("created_at desc").
Limit(50).
Find(&rsvps)
var danmakus []models.Danmaku
visitorFilter(db.Model(&models.Danmaku{})).
Order("created_at desc").
Limit(100).
Find(&danmakus)
likeItems := make([]gin.H, 0, len(likes))
for _, row := range likes {
likeItems = append(likeItems, gin.H{
"id": row.ID,
"created_at": row.CreatedAt,
})
}
giftItems := make([]gin.H, 0, len(gifts))
for _, g := range gifts {
giftItems = append(giftItems, gin.H{
"id": g.ID,
"gift_type": g.GiftType,
"name": g.Name,
"cost": g.Cost,
"created_at": g.CreatedAt,
})
}
rsvpItems := make([]gin.H, 0, len(rsvps))
for _, r := range rsvps {
rsvpItems = append(rsvpItems, gin.H{
"id": r.ID,
"name": r.Name,
"guest_count": r.GuestCount,
"wishes": r.Wishes,
"created_at": r.CreatedAt,
})
}
danmakuItems := make([]gin.H, 0, len(danmakus))
for _, d := range danmakus {
danmakuItems = append(danmakuItems, gin.H{
"id": d.ID,
"name": d.Name,
"content": d.Content,
"status": d.Status,
"source": d.Source,
"color": d.Color,
"created_at": d.CreatedAt,
})
}
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"visitor": gin.H{
"fingerprint": fp,
"client_ids": clientIDs,
"ip": latest.IP,
"raw_location": latest.RawLocation,
"region": latest.Region,
"city": latest.City,
"isp": latest.ISP,
"user_agent": latest.UserAgent,
"device": parseDeviceFromUA(latest.UserAgent),
"visit_count": visitCountSum,
"visit_rows": len(visits),
"first_seen_at": firstSeen,
"last_seen_at": lastSeen,
},
"like_count": likeCount,
"likes": likeItems,
"gift_count": giftCount,
"gift_type_counts": giftTypeCounts,
"gifts": giftItems,
"rsvps": rsvpItems,
"danmakus": danmakuItems,
},
})
}

View File

@@ -144,22 +144,31 @@ export function getLikeStatus(clientId) {
return service.get('/like', { params: { client_id: clientId } })
}
export function submitLike(clientId, count = 1) {
return service.post('/like', { client_id: clientId, count })
export function submitLike(clientId, count = 1, fingerprint = '') {
return service.post('/like', {
client_id: clientId,
fingerprint: fingerprint || '',
count,
})
}
export function getGiftStatus(clientId) {
return service.get('/gift', { params: { client_id: clientId } })
}
export function submitGift({ giftType, name, clientId }) {
export function submitGift({ giftType, name, clientId, fingerprint }) {
return service.post('/gift', {
gift_type: giftType,
name,
client_id: clientId,
fingerprint: fingerprint || '',
})
}
export function getVisitDetail(fingerprint) {
return service.get('/visit/detail', { params: { fingerprint } })
}
export function getGiftStats() {
return service.get('/gift/stats')
}

View File

@@ -1,6 +1,8 @@
const STORAGE_KEY = 'wedding_music_pick_v1'
const HOUR_MS = 60 * 60 * 1000
export const MUSIC_PLAY_MODES = ['sequential', 'reverse', 'random', 'single']
/** 随机权重:默认 1最小 1最大 1000 */
export function normalizeMusicWeight(w) {
const n = Number(w)
@@ -15,6 +17,20 @@ export function normalizeMusicCacheHours(h) {
return Math.min(720, Math.max(0.5, Math.round(n * 10) / 10))
}
/**
* 解析播放模式。
* - 有 musicPlayMode 且合法 → 用之
* - 否则兼容旧 musicRandomEnabledtrue→randomfalse/缺省→single
* - 新建默认由调用方写 random
*/
export function resolveMusicPlayMode(cfg = {}) {
const mode = String(cfg?.musicPlayMode || '').trim().toLowerCase()
if (MUSIC_PLAY_MODES.includes(mode)) return mode
if (cfg?.musicRandomEnabled === true) return 'random'
if (cfg?.musicRandomEnabled === false) return 'single'
return 'random'
}
function readCache() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
@@ -70,14 +86,31 @@ export function pickWeightedMusicUrl(musicList, opts = {}) {
/**
* 解析首播曲目。
* options:
* - cacheEnabled: 是否启用本地记忆
* - cacheHours: 记忆时长(小时)
* - mode / musicPlayMode: sequential|reverse|random|single
* - cacheEnabled / cacheHours: 仅 random 首播记忆
* - excludeUrl
*/
export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabled, options = {}) {
export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabledOrOptions, maybeOptions) {
const list = Array.isArray(musicList) ? musicList.filter((t) => t && t.url) : []
if (!list.length) return ''
if (!musicRandomEnabled) {
// 兼容旧签名 pickMusicUrl(list, active, boolean, options)
let options = {}
if (musicRandomEnabledOrOptions && typeof musicRandomEnabledOrOptions === 'object') {
options = musicRandomEnabledOrOptions
} else {
options = {
...(maybeOptions || {}),
musicRandomEnabled: musicRandomEnabledOrOptions,
}
}
const mode = resolveMusicPlayMode({
musicPlayMode: options.mode || options.musicPlayMode,
musicRandomEnabled: options.musicRandomEnabled,
})
if (mode !== 'random') {
if (activeMusicUrl && list.some((t) => t.url === activeMusicUrl)) return activeMusicUrl
return list[0].url
}

View File

@@ -5,12 +5,27 @@ import { getClientId } from '@/utils/clientId'
const SESSION_KEY = 'wedding_visit_beacon_v1'
let fpPromise = null
let cachedFingerprint = ''
function getFpAgent() {
if (!fpPromise) fpPromise = FingerprintJS.load()
return fpPromise
}
/** 获取浏览器指纹(同页缓存);失败返回空串 */
export async function getFingerprint() {
if (cachedFingerprint) return cachedFingerprint
try {
const agent = await getFpAgent()
const result = await agent.get()
const fingerprint = result?.visitorId || ''
if (fingerprint) cachedFingerprint = fingerprint
return fingerprint
} catch {
return ''
}
}
/** 同会话只上报一次;失败静默 */
export async function sendVisitBeacon() {
try {
@@ -18,9 +33,7 @@ export async function sendVisitBeacon() {
} catch { /* ignore */ }
try {
const agent = await getFpAgent()
const result = await agent.get()
const fingerprint = result?.visitorId
const fingerprint = await getFingerprint()
if (!fingerprint) return
await trackVisit({

View File

@@ -183,7 +183,7 @@
</div>
<div class="visit-preview-wrap">
<a-table
class="admin-table"
class="admin-table visit-table-clickable"
:columns="visitPreviewColumns"
:data-source="visitPreviewList"
:loading="visitListLoading"
@@ -191,10 +191,11 @@
size="small"
:pagination="false"
:scroll="{ y: 260 }"
:custom-row="visitTableCustomRow"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fingerprint'">
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
<span class="font-mono text-[11px] text-[#a88c6b] underline-offset-2 hover:underline" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
</template>
<template v-else-if="column.key === 'location'">
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
@@ -306,9 +307,10 @@
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
</a-button>
</div>
<div class="text-[11px] text-[#8A8680] mb-2">点击行查看该访客的回执弹幕点赞与礼物详情</div>
<div class="visit-list-wrap">
<a-table
class="admin-table"
class="admin-table visit-table-clickable"
:columns="visitColumns"
:data-source="visitList"
:loading="visitListLoading"
@@ -316,11 +318,12 @@
size="middle"
:pagination="visitPagination"
:scroll="{ y: 'calc(100dvh - 280px)', x: 960 }"
:custom-row="visitTableCustomRow"
@change="onVisitTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fingerprint'">
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
<span class="font-mono text-[11px] text-[#a88c6b] underline-offset-2 hover:underline" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
</template>
<template v-else-if="column.key === 'location'">
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
@@ -340,6 +343,84 @@
</div>
</section>
<a-modal
v-model:open="visitDetailOpen"
:title="visitDetailTitle"
:footer="null"
width="720px"
destroy-on-close
>
<div v-if="visitDetailLoading" class="py-10 text-center text-[#8A8680] text-[13px]">加载中</div>
<div v-else-if="visitDetail" class="visit-detail">
<div class="visit-detail-summary">
<div class="stat-card !mb-0">
<div class="stat-label">浏览次数</div>
<div class="stat-value !text-xl">{{ visitDetail.visitor?.visit_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">点赞</div>
<div class="stat-value !text-xl">{{ visitDetail.like_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">礼物</div>
<div class="stat-value !text-xl">{{ visitDetail.gift_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">回执</div>
<div class="stat-value !text-xl">{{ (visitDetail.rsvps || []).length }}</div>
</div>
</div>
<div class="text-[12px] text-[#5c5348] mt-3 space-y-1">
<div>归属地{{ visitDetail.visitor?.raw_location || [visitDetail.visitor?.region, visitDetail.visitor?.city].filter(Boolean).join(' ') || '未知' }}</div>
<div>机型{{ visitDetail.visitor?.device || '未知' }}</div>
<div>首次{{ formatTime(visitDetail.visitor?.first_seen_at) }} · 最近{{ formatTime(visitDetail.visitor?.last_seen_at) }}</div>
<div class="font-mono text-[11px] text-[#8A8680] break-all">指纹{{ visitDetail.visitor?.fingerprint }}</div>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">回执</div>
<div v-if="!(visitDetail.rsvps || []).length" class="text-[12px] text-[#8A8680]">暂无回执</div>
<ul v-else class="visit-detail-list">
<li v-for="r in visitDetail.rsvps" :key="'rsvp-' + r.id">
<div class="font-medium text-[#2c2c2c]">{{ r.name || '—' }} · {{ r.guest_count || '1' }} </div>
<div v-if="r.wishes" class="text-[#5c5348] mt-0.5 whitespace-pre-wrap">{{ r.wishes }}</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">{{ formatTime(r.created_at) }}</div>
</li>
</ul>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">弹幕</div>
<div v-if="!(visitDetail.danmakus || []).length" class="text-[12px] text-[#8A8680]">暂无弹幕</div>
<ul v-else class="visit-detail-list">
<li v-for="d in visitDetail.danmakus" :key="'dm-' + d.id">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-[#2c2c2c]">{{ d.name || '—' }}</span>
<span class="admin-pill admin-pill--muted">{{ d.source === 'rsvp' ? '回执' : '弹幕' }}</span>
<span class="admin-pill" :class="danmakuStatusPill(d.status)">{{ danmakuStatusLabel(d.status) }}</span>
</div>
<div class="text-[#5c5348] mt-0.5 whitespace-pre-wrap">{{ d.content }}</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">{{ formatTime(d.created_at) }}</div>
</li>
</ul>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">礼物明细</div>
<div v-if="!(visitDetail.gifts || []).length" class="text-[12px] text-[#8A8680]">暂无礼物</div>
<ul v-else class="visit-detail-list">
<li v-for="g in visitDetail.gifts" :key="'gift-' + g.id">
<div class="font-medium text-[#2c2c2c]">
{{ g.name || '—' }} · {{ giftTypeLabel(g.gift_type) }}
<span class="text-[#8A8680] font-normal">消耗 {{ g.cost || 0 }}</span>
</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">{{ formatTime(g.created_at) }}</div>
</li>
</ul>
</div>
</div>
</a-modal>
<!-- 基础信息 -->
<section v-if="activeTab === 'basic'">
<a-card :bordered="false" class="!shadow-sm" :class="{ 'opacity-60': tabLoading }">
@@ -524,49 +605,54 @@
<div>
<div class="text-sm text-[#2c2c2c] tracking-wide">曲目列表</div>
<p class="text-[11px] text-[#8A8680] mt-1 leading-relaxed max-w-xl">
开启随机后按<strong>权重</strong>抽曲播完自动再随机下一首不单曲循环
可开启记忆曲目让同一访客在设定时长内首次打开仍播同一首
在下方选择<strong>播放设置</strong>随机模式按权重抽曲可开启记忆曲目让同一访客在设定时长内首次打开仍播同一首
</p>
</div>
<div class="flex flex-wrap items-center gap-3 justify-end">
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
<a-switch v-model:checked="cfg.musicRandomEnabled" size="small" />
随机播放
</label>
<a-button type="primary" size="small"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@click="openMusicUpload">
<i class="fa-solid fa-plus mr-1"></i>上传音乐
</a-button>
</div>
<a-button type="primary" size="small"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@click="openMusicUpload">
<i class="fa-solid fa-plus mr-1"></i>上传音乐
</a-button>
</div>
<div
v-if="cfg.musicRandomEnabled"
class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[#e7e3db] bg-[#faf8f5] px-3 py-2.5"
>
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
<a-switch v-model:checked="cfg.musicCacheEnabled" size="small" />
记忆曲目
</label>
<div class="inline-flex items-center gap-2 text-[12px] text-[#5c5348]">
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }">记忆时长</span>
<a-input-number
v-model:value="cfg.musicCacheHours"
:min="0.5"
:max="720"
:step="0.5"
size="small"
class="!w-[96px]"
:disabled="!cfg.musicCacheEnabled"
/>
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }" class="text-[#8A8680]">小时</span>
<div class="mb-4 rounded-xl border border-[#e7e3db] bg-[#faf8f5] px-3 py-3">
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">播放设置</div>
<a-radio-group v-model:value="cfg.musicPlayMode" class="music-play-mode-group">
<a-radio-button value="sequential">顺序</a-radio-button>
<a-radio-button value="reverse">倒序</a-radio-button>
<a-radio-button value="random">随机</a-radio-button>
<a-radio-button value="single">单曲循环</a-radio-button>
</a-radio-group>
<div class="text-[10px] text-[#8A8680] mt-2 leading-relaxed">
{{ musicPlayModeHint }}
</div>
<div
v-if="cfg.musicPlayMode === 'random'"
class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-[#e7e3db] pt-3"
>
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
<a-switch v-model:checked="cfg.musicCacheEnabled" size="small" />
记忆曲目
</label>
<div class="inline-flex items-center gap-2 text-[12px] text-[#5c5348]">
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }">记忆时长</span>
<a-input-number
v-model:value="cfg.musicCacheHours"
:min="0.5"
:max="720"
:step="0.5"
size="small"
class="!w-[96px]"
:disabled="!cfg.musicCacheEnabled"
/>
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }" class="text-[#8A8680]">小时</span>
</div>
<span class="text-[10px] text-[#8A8680]">
{{ cfg.musicCacheEnabled
? `同一设备 ${cfg.musicCacheHours || 24} 小时内首次打开沿用上次抽中的歌;播完仍会换下一首。`
: '关闭后每次打开页面都会重新按权重抽曲。' }}
</span>
</div>
<span class="text-[10px] text-[#8A8680]">
{{ cfg.musicCacheEnabled
? `同一设备 ${cfg.musicCacheHours || 24} 小时内首次打开沿用上次抽中的歌;播完仍会换下一首。`
: '关闭后每次打开页面都会重新按权重抽曲。' }}
</span>
</div>
<div v-if="!cfg.musicList.length" class="admin-empty">
@@ -616,8 +702,8 @@
<div class="music-chips">
<label
class="music-chip music-chip--weight"
:class="{ 'opacity-40': !cfg.musicRandomEnabled }"
:title="cfg.musicRandomEnabled ? '随机权重,越大越容易抽到' : '开启随机播放后生效'"
:class="{ 'opacity-40': cfg.musicPlayMode !== 'random' }"
:title="cfg.musicPlayMode === 'random' ? '随机权重,越大越容易抽到' : '随机模式下生效'"
>
<i class="fa-solid fa-scale-balanced"></i>
<span>权重</span>
@@ -628,9 +714,9 @@
:step="1"
size="small"
class="music-weight-input"
:disabled="!cfg.musicRandomEnabled"
:disabled="cfg.musicPlayMode !== 'random'"
/>
<span v-if="cfg.musicRandomEnabled" class="music-weight-pct">
<span v-if="cfg.musicPlayMode === 'random'" class="music-weight-pct">
{{ musicWeightPercent(t) }}%
</span>
</label>
@@ -1573,7 +1659,7 @@ import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import {
getConfigSection, saveConfigSection, uploadImage, getRsvpList, deleteRsvp, getUploadConfig, saveUploadConfig,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitCityStats, getVisitList,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitCityStats, getVisitList, getVisitDetail,
getPosterConfig, savePosterConfig, getGiftStats, getGiftList, deleteGift, saveGiftCosts,
getCashGiftList, updateCashGift, deleteCashGift, submitCashGift,
getCashGiftNotes, saveCashGiftNotes,
@@ -1586,7 +1672,9 @@ import MediaPickerModal from '@/components/MediaPickerModal.vue'
import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { normalizeMusicWeight, normalizeMusicCacheHours } from '@/utils/musicPick'
import {
normalizeMusicWeight, normalizeMusicCacheHours, resolveMusicPlayMode,
} from '@/utils/musicPick'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import {
getSlotResizeSpecs,
@@ -1676,7 +1764,7 @@ const SECTION_KEYS = {
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
'petalEnabled', 'bubbleEnabled', 'introEnabled', 'danmakuEnabled', 'danmakuShowTime', 'nineGridAnimate',
],
music: ['musicList', 'activeMusicUrl', 'musicRandomEnabled', 'musicCacheEnabled', 'musicCacheHours'],
music: ['musicList', 'activeMusicUrl', 'musicPlayMode', 'musicRandomEnabled', 'musicCacheEnabled', 'musicCacheHours'],
photos: ['photoPages'],
schedule: ['schedule'],
}
@@ -1745,6 +1833,13 @@ const danmakuFilter = ref('pending')
const visitStatsLoading = ref(false)
const visitListLoading = ref(false)
const visitDetailOpen = ref(false)
const visitDetailLoading = ref(false)
const visitDetail = ref(null)
const visitDetailTitle = computed(() => {
const fp = visitDetail.value?.visitor?.fingerprint
return fp ? `访客详情 · ${shortFp(fp)}` : '访客详情'
})
const giftStatsLoading = ref(false)
const giftListLoading = ref(false)
const giftCostsSaving = ref(false)
@@ -1986,7 +2081,8 @@ function defaultConfig() {
endImgResizedMeta: '',
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1', weight: 1 }],
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
musicRandomEnabled: false,
musicPlayMode: 'random',
musicRandomEnabled: true,
musicCacheEnabled: true,
musicCacheHours: 24,
danmakuEnabled: true,
@@ -2523,6 +2619,14 @@ function musicWeightPercent(track) {
if (!total) return 0
return Math.round((normalizeMusicWeight(track?.weight) / total) * 100)
}
const MUSIC_PLAY_MODE_HINTS = {
sequential: '按列表顺序播放,播完自动下一首,到末尾回到第一首。',
reverse: '按列表倒序播放,播完自动上一首,到开头回到最后一首。',
random: '按权重随机抽曲,播完再抽下一首;可配置记忆与权重。',
single: '单曲循环当前曲(「设为播放」指定的曲目)。',
}
const musicPlayModeHint = computed(() => MUSIC_PLAY_MODE_HINTS[cfg.musicPlayMode] || MUSIC_PLAY_MODE_HINTS.random)
function shortUrl(url) {
if (!url) return ''
const parts = url.split('/')
@@ -2762,11 +2866,15 @@ function pickSectionData(section) {
const data = {}
for (const k of keys) data[k] = cfg[k]
const cloned = JSON.parse(JSON.stringify(data))
if (section === 'music' && Array.isArray(cloned.musicList)) {
cloned.musicList = cloned.musicList.map((t) => ({
...t,
weight: normalizeMusicWeight(t?.weight),
}))
if (section === 'music') {
if (Array.isArray(cloned.musicList)) {
cloned.musicList = cloned.musicList.map((t) => ({
...t,
weight: normalizeMusicWeight(t?.weight),
}))
}
cloned.musicPlayMode = resolveMusicPlayMode(cloned)
cloned.musicRandomEnabled = cloned.musicPlayMode === 'random'
cloned.musicCacheEnabled = cloned.musicCacheEnabled !== false
cloned.musicCacheHours = normalizeMusicCacheHours(cloned.musicCacheHours)
}
@@ -2784,7 +2892,8 @@ function applySectionData(section, loaded) {
weight: normalizeMusicWeight(t.weight),
}))
}
if (typeof loaded.musicRandomEnabled !== 'boolean') loaded.musicRandomEnabled = false
loaded.musicPlayMode = resolveMusicPlayMode(loaded)
loaded.musicRandomEnabled = loaded.musicPlayMode === 'random'
if (typeof loaded.musicCacheEnabled !== 'boolean') loaded.musicCacheEnabled = true
loaded.musicCacheHours = normalizeMusicCacheHours(loaded.musicCacheHours)
}
@@ -2885,6 +2994,28 @@ function shortUa(ua) {
return ua.length > 42 ? `${ua.slice(0, 42)}` : ua
}
const visitTableCustomRow = (record) => ({
onClick: () => openVisitDetail(record?.fingerprint),
style: { cursor: 'pointer' },
})
async function openVisitDetail(fingerprint) {
const fp = String(fingerprint || '').trim()
if (!fp) return
visitDetailOpen.value = true
visitDetailLoading.value = true
visitDetail.value = null
try {
const res = await getVisitDetail(fp)
visitDetail.value = res.data || null
} catch (e) {
message.error(e?.message || '加载访客详情失败')
visitDetailOpen.value = false
} finally {
visitDetailLoading.value = false
}
}
const PIE_COLORS = ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b']
function disposeVisitCharts() {
@@ -3820,6 +3951,47 @@ onUnmounted(() => {
.visit-list-wrap :deep(.ant-table-body) {
max-height: inherit;
}
.visit-table-clickable :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(168, 140, 107, 0.08) !important;
}
.visit-detail-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.visit-detail-section {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(168, 140, 107, 0.14);
}
.visit-detail-h {
font-size: 13px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 8px;
}
.visit-detail-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.visit-detail-list li {
font-size: 12px;
padding: 8px 10px;
border-radius: 10px;
background: #fdfbf7;
border: 1px solid rgba(168, 140, 107, 0.1);
}
@media (max-width: 640px) {
.visit-detail-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.chart-card {
border-radius: 14px;
padding: 14px 16px;

View File

@@ -852,7 +852,9 @@ import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from
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, pickWeightedMusicUrl, rememberMusicPick } from '@/utils/musicPick'
import {
pickMusicUrl, pickWeightedMusicUrl, rememberMusicPick, resolveMusicPlayMode,
} from '@/utils/musicPick'
import { getClientId } from '@/utils/clientId'
import { getGuestName, setGuestName } from '@/utils/guestName'
import {
@@ -863,7 +865,7 @@ import {
chunkGiftTypes,
LIKE_QUICK_AMOUNTS,
} from '@/utils/giftTypes'
import { sendVisitBeacon } from '@/utils/visitBeacon'
import { sendVisitBeacon, getFingerprint } from '@/utils/visitBeacon'
import { optimizeImageUrl, isIOS } from '@/composables/useAndroidOptimize'
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
@@ -877,7 +879,7 @@ import PhotoGallery from '@/components/PhotoGallery.vue'
const DEFAULTS = {
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
musicList: [], activeMusicUrl: '', musicRandomEnabled: false,
musicList: [], activeMusicUrl: '', musicPlayMode: 'random', musicRandomEnabled: true,
musicCacheEnabled: true, musicCacheHours: 24,
danmakuEnabled: true, danmakuShowTime: true, nineGridAnimate: false,
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms越小越快
@@ -926,13 +928,16 @@ const toggleVolumeSlider = () => {
showVolumeSlider.value = !showVolumeSlider.value
if (showVolumeSlider.value) showPanelTrackList.value = false
}
const currentMusicPlayMode = () => resolveMusicPlayMode(data.value)
const musicCacheOptions = () => ({
mode: currentMusicPlayMode(),
cacheEnabled: data.value.musicCacheEnabled !== false,
cacheHours: data.value.musicCacheHours,
})
const rememberIfCached = (url) => {
if (data.value.musicRandomEnabled && data.value.musicCacheEnabled !== false && url) {
if (currentMusicPlayMode() === 'random' && data.value.musicCacheEnabled !== false && url) {
rememberMusicPick(url)
}
}
@@ -948,21 +953,30 @@ const playRandomTrack = async (excludeUrl) => {
}
const syncMusicPlaybackMode = () => {
const random = !!data.value.musicRandomEnabled
// 随机:不单曲循环,播完再抽;非随机:单曲循环当前曲
audio.setLoop(!random)
// single → 原生循环;其余模式播完由 onEnded 接管
audio.setLoop(currentMusicPlayMode() === 'single')
}
const playPrevTrack = () => {
audio.prev()
const mode = currentMusicPlayMode()
if (mode === 'reverse') {
audio.next()
} else {
audio.prev()
}
rememberIfCached(audio.activeUrl.value)
}
const playNextTrack = () => {
if (data.value.musicRandomEnabled && (data.value.musicList || []).filter((t) => t?.url).length > 1) {
const mode = currentMusicPlayMode()
if (mode === 'random' && (data.value.musicList || []).filter((t) => t?.url).length > 1) {
playRandomTrack(audio.activeUrl.value)
return
}
audio.next()
if (mode === 'reverse') {
audio.prev()
} else {
audio.next()
}
rememberIfCached(audio.activeUrl.value)
}
const activeLrcIndex = computed(() => findLrcIndex(lrcLines.value, audio.currentTime.value))
@@ -1868,7 +1882,7 @@ const selectTrack = (url) => {
audio.setActive(url)
showPanelTrackList.value = false
showTrackList.value = false
if (data.value.musicRandomEnabled) rememberIfCached(url)
if (currentMusicPlayMode() === 'random') rememberIfCached(url)
}
const mapKeyword = computed(() => `${data.value.hotel || ''} ${data.value.address || ''}`.replace(/\s+/g, ' ').trim())
@@ -1939,10 +1953,16 @@ const pickAiStyle = async (target, style) => {
}
}
const visitorIds = async () => {
const fingerprint = await getFingerprint()
return { fingerprint: fingerprint || '', client_id: getClientId() }
}
const handleSubmitRsvp = async () => {
if (!rsvpForm.name.trim()) return toast.error('请填写姓名')
try {
const res = await submitRsvp(rsvpForm)
const ids = await visitorIds()
const res = await submitRsvp({ ...rsvpForm, ...ids })
if (res.code === 200) {
setGuestName(rsvpForm.name)
syncGuestNameIntoForms()
@@ -1957,10 +1977,12 @@ const handleSubmitBlessing = async () => {
if (!blessingForm.name.trim()) return toast.error('请填写姓名')
if (!blessingForm.content.trim()) return toast.error('请填写祝福')
try {
const ids = await visitorIds()
const res = await submitDanmaku({
name: blessingForm.name.trim(),
content: blessingForm.content.trim(),
color: blessingForm.color || DEFAULT_DANMAKU_COLOR,
...ids,
})
if (res.code === 200) {
setGuestName(blessingForm.name)
@@ -2057,10 +2079,12 @@ const openGiftNameModal = (giftType) => {
const sendGiftRequest = async (giftType, name) => {
giftBusy.value = true
try {
const fingerprint = await getFingerprint()
const res = await submitGift({
giftType,
name,
clientId: getClientId(),
fingerprint,
})
setGuestName(name)
giftForm.name = name
@@ -2146,7 +2170,8 @@ const handleLike = async (count = 1) => {
const n = Math.max(1, Math.min(200, Number(count) || 1))
likeBusy.value = true
try {
const res = await submitLike(getClientId(), n)
const fingerprint = await getFingerprint()
const res = await submitLike(getClientId(), n, fingerprint)
likeCount.value = Number(res.data?.count) || likeCount.value + n
likePulse.value = true
clearTimeout(likePulseTimer)
@@ -2231,16 +2256,28 @@ onMounted(async () => {
// 未开启揭幕时Loading 结束后再播积压特效
if (!data.value.introEnabled) flushPendingEntranceFx()
// 兼容旧配置:补全 musicPlayMode
data.value.musicPlayMode = resolveMusicPlayMode(data.value)
data.value.musicRandomEnabled = data.value.musicPlayMode === 'random'
const pickedUrl = pickMusicUrl(
data.value.musicList,
data.value.activeMusicUrl,
data.value.musicRandomEnabled,
musicCacheOptions(),
)
syncMusicPlaybackMode()
audio.onEnded(() => {
if (!data.value.musicRandomEnabled) return
playRandomTrack(audio.activeUrl.value)
const mode = currentMusicPlayMode()
if (mode === 'single') return
if (mode === 'random') {
playRandomTrack(audio.activeUrl.value)
return
}
if (mode === 'reverse') {
audio.prev()
return
}
audio.next()
})
audio.setTracks(data.value.musicList, pickedUrl)
let autoMusicOk = true