From 76b894c44639995733a421327764d2f726732318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Mon, 3 Aug 2026 10:27:56 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E6=B5=B7=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hunliji-api/visit/visit.go | 138 ++++++++- .../src/views/admin/AdminEditor.vue | 266 +++++++++++++----- 2 files changed, 338 insertions(+), 66 deletions(-) diff --git a/hunliji-api/visit/visit.go b/hunliji-api/visit/visit.go index 8f2a8ad..120c118 100644 --- a/hunliji-api/visit/visit.go +++ b/hunliji-api/visit/visit.go @@ -6,6 +6,7 @@ import ( "io" "log" "net/http" + "sort" "strconv" "strings" "sync" @@ -251,6 +252,117 @@ func truncateRunes(s string, max int) string { return string(r[:max]) } +// parseDeviceFromUA 从 UA 解析机型/平台,用于用户画像统计 +func parseDeviceFromUA(ua string) string { + ua = strings.TrimSpace(ua) + if ua == "" { + return "未知" + } + lower := strings.ToLower(ua) + + if strings.Contains(ua, "iPhone") { + return "iPhone" + } + if strings.Contains(ua, "iPad") { + return "iPad" + } + if strings.Contains(lower, "android") { + if model := extractAndroidModel(ua); model != "" { + return model + } + return "Android" + } + if strings.Contains(lower, "windows phone") { + return "Windows Phone" + } + if strings.Contains(lower, "windows") { + return "Windows" + } + if strings.Contains(ua, "Macintosh") || strings.Contains(lower, "mac os") { + return "Mac" + } + if strings.Contains(lower, "linux") { + return "Linux" + } + return "其他" +} + +func extractAndroidModel(ua string) string { + // 常见形态:Linux; Android 13; SM-S9180 Build/... 或 Android 12; Pixel 7) + idx := strings.Index(ua, "Android") + if idx < 0 { + return "" + } + rest := ua[idx:] + semi := strings.Index(rest, ";") + if semi < 0 { + return "" + } + rest = strings.TrimSpace(rest[semi+1:]) + end := len(rest) + for _, sep := range []string{" Build", ")", "; HMSCore", "; HarmonyOS"} { + if p := strings.Index(rest, sep); p >= 0 && p < end { + end = p + } + } + model := strings.TrimSpace(rest[:end]) + model = strings.Trim(model, "; ") + if model == "" || strings.EqualFold(model, "wv") || strings.HasPrefix(strings.ToLower(model), "linux") { + return "" + } + // 过长型号截断 + r := []rune(model) + if len(r) > 40 { + model = string(r[:40]) + } + return model +} + +func aggregateDevices() []map[string]interface{} { + type uaRow struct { + Fingerprint string + UserAgent string + LastSeenAt time.Time + } + var rows []uaRow + db.Model(&SiteVisit{}). + Select("fingerprint, user_agent, last_seen_at"). + Order("last_seen_at desc"). + Find(&rows) + + seen := map[string]struct{}{} + counts := map[string]int64{} + for _, r := range rows { + if _, ok := seen[r.Fingerprint]; ok { + continue + } + seen[r.Fingerprint] = struct{}{} + name := parseDeviceFromUA(r.UserAgent) + counts[name]++ + } + + type kv struct { + Name string + Value int64 + } + list := make([]kv, 0, len(counts)) + for name, value := range counts { + list = append(list, kv{Name: name, Value: value}) + } + sort.Slice(list, func(i, j int) bool { return list[i].Value > list[j].Value }) + if len(list) > 30 { + list = list[:30] + } + out := make([]map[string]interface{}, 0, len(list)) + for _, item := range list { + out = append(out, map[string]interface{}{ + "name": item.Name, + "value": item.Value, + }) + } + return out +} + func handleTrackVisit(c *gin.Context) { var body struct { Fingerprint string `json:"fingerprint"` @@ -372,6 +484,8 @@ func handleVisitStats(c *gin.Context) { regions = []regionRow{} } + devices := aggregateDevices() + c.JSON(200, gin.H{ "code": 200, "data": gin.H{ @@ -379,6 +493,7 @@ func handleVisitStats(c *gin.Context) { "pv": pv, "today_uv": todayUV, "regions": regions, + "devices": devices, }, }) } @@ -408,10 +523,31 @@ func handleVisitList(c *gin.Context) { list = []SiteVisit{} } + enriched := make([]gin.H, 0, len(list)) + for _, row := range list { + enriched = append(enriched, gin.H{ + "id": row.ID, + "fingerprint": row.Fingerprint, + "client_id": row.ClientID, + "ip": row.IP, + "country": row.Country, + "region": row.Region, + "city": row.City, + "isp": row.ISP, + "raw_location": row.RawLocation, + "user_agent": row.UserAgent, + "device": parseDeviceFromUA(row.UserAgent), + "referer": row.Referer, + "visit_count": row.VisitCount, + "first_seen_at": row.FirstSeenAt, + "last_seen_at": row.LastSeenAt, + }) + } + c.JSON(200, gin.H{ "code": 200, "data": gin.H{ - "list": list, + "list": enriched, "total": total, "page": page, "pageSize": pageSize, diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index 595887a..6bee0a1 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -54,9 +54,9 @@
访客概览
-
指纹去重访客与地区分布
+
指纹去重访客 · 地区 / 机型画像
- + 刷新
@@ -82,41 +82,90 @@
-
说明
- +
机型画像
+
-
近期访客
- -