更新了机型映射、背景音乐播放逻辑
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 游戏平板", // 补充(平板类,按需保留)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 且合法 → 用之
|
||||
* - 否则兼容旧 musicRandomEnabled:true→random,false/缺省→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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user