From 51b40026df7e74e5a4926c11fad7005c59ebbb50 Mon Sep 17 00:00:00 2001 From: lq Date: Sat, 29 Aug 2026 14:19:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8D=87=E7=BA=A7=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E8=A7=86=E8=A7=89=E3=80=81=E5=90=84=E7=A7=8D=E5=8D=95?= =?UTF-8?q?=E5=9B=BE=E7=9B=B8=E5=86=8C=E6=95=88=E6=9E=9C=E3=80=81=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E6=95=88=E6=9E=9C=E3=80=81=E5=A4=9A=E7=A7=8D=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E4=BB=BB=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hunliji-api/visit/visit.go | 398 ++++++++++++++++-- .../src/views/admin/AdminEditor.vue | 176 ++++++-- 2 files changed, 525 insertions(+), 49 deletions(-) diff --git a/hunliji-api/visit/visit.go b/hunliji-api/visit/visit.go index 10af82c..6773487 100644 --- a/hunliji-api/visit/visit.go +++ b/hunliji-api/visit/visit.go @@ -853,10 +853,26 @@ func resolveVisitCity(city, raw, province string) string { return "未知" } -func handleVisitList(c *gin.Context) { - if !requireAdmin(c) { - return - } +type visitListQuery struct { + Page int + PageSize int + Region string + City string + HasInteraction bool + HasGift bool + Sort string +} + +type visitEnrichment struct { + GuestNames []string + GiftCount int64 + LikeCount int64 + RsvpCount int64 + DanmakuCount int64 + HasInteraction bool +} + +func parseVisitListQuery(c *gin.Context) visitListQuery { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) if page < 1 { @@ -865,37 +881,369 @@ func handleVisitList(c *gin.Context) { if pageSize < 1 || pageSize > 100 { pageSize = 20 } + sort := strings.TrimSpace(c.DefaultQuery("sort", "last_seen_desc")) + switch sort { + case "last_seen_desc", "last_seen_asc", "visit_count_desc", "visit_count_asc": + default: + sort = "last_seen_desc" + } + return visitListQuery{ + Page: page, + PageSize: pageSize, + Region: strings.TrimSpace(c.Query("region")), + City: strings.TrimSpace(c.Query("city")), + HasInteraction: c.Query("has_interaction") == "1" || strings.EqualFold(c.Query("has_interaction"), "true"), + HasGift: c.Query("has_gift") == "1" || strings.EqualFold(c.Query("has_gift"), "true"), + Sort: sort, + } +} + +func applyRegionWhere(q *gorm.DB, region string) *gorm.DB { + target := normalizeRegionName(region) + if target == "未知" { + return q.Where("region = '' OR region IS NULL OR region = ? OR region LIKE ?", "未知", "%未知%") + } + if target == "本地" { + return q.Where("region = ? OR raw_location = ?", "本地", "本地") + } + like := "%" + target + "%" + return q.Where("region = ? OR region LIKE ? OR raw_location LIKE ?", target, like, like) +} + +// fingerprintsForRegionCity 按指纹去重,与市级分布统计口径一致 +func fingerprintsForRegionCity(region, city string) []string { + targetRegion := normalizeRegionName(region) + targetCity := strings.TrimSpace(city) + + type locRow struct { + Fingerprint string + Region string + City string + RawLocation string + } + var rows []locRow + q := db.Model(&SiteVisit{}).Select("fingerprint, region, city, raw_location") + q = applyRegionWhere(q, targetRegion) + if err := q.Find(&rows).Error; err != nil { + return nil + } + + fpCity := map[string]string{} + for _, row := range rows { + if !rowBelongsToRegion(row.Region, row.RawLocation, targetRegion) { + continue + } + c := resolveVisitCity(row.City, row.RawLocation, targetRegion) + if prev, ok := fpCity[row.Fingerprint]; ok { + if prev != "未知" || c == "未知" { + continue + } + } + fpCity[row.Fingerprint] = c + } + + out := make([]string, 0) + for fp, c := range fpCity { + if targetCity != "" && c != targetCity { + continue + } + out = append(out, fp) + } + return out +} + +func visitorFingerprintsWithGift() map[string]struct{} { + result := map[string]struct{}{} + var fps []string + db.Model(&models.SiteGift{}).Distinct().Where("fingerprint != ''").Pluck("fingerprint", &fps) + for _, fp := range fps { + result[fp] = struct{}{} + } + var cids []string + db.Model(&models.SiteGift{}).Distinct().Where("client_id != ''").Pluck("client_id", &cids) + if len(cids) > 0 { + var visitFps []string + db.Model(&SiteVisit{}).Where("client_id IN ?", cids).Distinct().Pluck("fingerprint", &visitFps) + for _, fp := range visitFps { + result[fp] = struct{}{} + } + } + return result +} + +func visitorFingerprintsWithInteraction() map[string]struct{} { + result := visitorFingerprintsWithGift() + addByField := func(model interface{}, fpField, cidField string) { + var fps []string + db.Model(model).Distinct().Where(fpField+" != ''").Pluck(fpField, &fps) + for _, fp := range fps { + result[fp] = struct{}{} + } + var cids []string + db.Model(model).Distinct().Where(cidField+" != ''").Pluck(cidField, &cids) + if len(cids) == 0 { + return + } + var visitFps []string + db.Model(&SiteVisit{}).Where("client_id IN ?", cids).Distinct().Pluck("fingerprint", &visitFps) + for _, fp := range visitFps { + result[fp] = struct{}{} + } + } + addByField(&models.SiteLike{}, "fingerprint", "client_id") + addByField(&models.Rsvp{}, "fingerprint", "client_id") + addByField(&models.Danmaku{}, "fingerprint", "client_id") + return result +} + +func mapKeys(m map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func visitListOrder(sort string) string { + switch sort { + case "visit_count_desc": + return "visit_count desc, last_seen_at desc" + case "visit_count_asc": + return "visit_count asc, last_seen_at desc" + case "last_seen_asc": + return "last_seen_at asc" + default: + return "last_seen_at desc" + } +} + +func appendUniqueName(names []string, name string) []string { + name = strings.TrimSpace(name) + if name == "" { + return names + } + for _, n := range names { + if n == name { + return names + } + } + return append(names, name) +} + +func batchEnrichVisits(rows []SiteVisit) map[string]visitEnrichment { + result := map[string]visitEnrichment{} + if len(rows) == 0 { + return result + } + + fps := make([]string, 0, len(rows)) + cids := make([]string, 0, len(rows)) + cidToFp := map[string]string{} + seenFp := map[string]struct{}{} + for _, row := range rows { + if row.Fingerprint != "" { + if _, ok := seenFp[row.Fingerprint]; !ok { + seenFp[row.Fingerprint] = struct{}{} + fps = append(fps, row.Fingerprint) + } + } + if row.ClientID != "" { + cids = append(cids, row.ClientID) + cidToFp[row.ClientID] = row.Fingerprint + } + } + + apply := func(fp string, fn func(*visitEnrichment)) { + if fp == "" { + return + } + e := result[fp] + fn(&e) + result[fp] = e + } + + var gifts []models.SiteGift + if len(fps) > 0 || len(cids) > 0 { + q := db.Model(&models.SiteGift{}) + if len(fps) > 0 && len(cids) > 0 { + q = q.Where("fingerprint IN ? OR client_id IN ?", fps, cids) + } else if len(fps) > 0 { + q = q.Where("fingerprint IN ?", fps) + } else { + q = q.Where("client_id IN ?", cids) + } + q.Find(&gifts) + } + for _, g := range gifts { + fp := g.Fingerprint + if fp == "" { + fp = cidToFp[g.ClientID] + } + apply(fp, func(e *visitEnrichment) { + e.GiftCount++ + e.GuestNames = appendUniqueName(e.GuestNames, g.Name) + e.HasInteraction = true + }) + } + + var likes []models.SiteLike + if len(fps) > 0 || len(cids) > 0 { + q := db.Model(&models.SiteLike{}) + if len(fps) > 0 && len(cids) > 0 { + q = q.Where("fingerprint IN ? OR client_id IN ?", fps, cids) + } else if len(fps) > 0 { + q = q.Where("fingerprint IN ?", fps) + } else { + q = q.Where("client_id IN ?", cids) + } + q.Find(&likes) + } + for _, row := range likes { + fp := row.Fingerprint + if fp == "" { + fp = cidToFp[row.ClientID] + } + apply(fp, func(e *visitEnrichment) { + e.LikeCount++ + e.HasInteraction = true + }) + } + + var rsvps []models.Rsvp + if len(fps) > 0 || len(cids) > 0 { + q := db.Model(&models.Rsvp{}) + if len(fps) > 0 && len(cids) > 0 { + q = q.Where("fingerprint IN ? OR client_id IN ?", fps, cids) + } else if len(fps) > 0 { + q = q.Where("fingerprint IN ?", fps) + } else { + q = q.Where("client_id IN ?", cids) + } + q.Find(&rsvps) + } + for _, r := range rsvps { + fp := r.Fingerprint + if fp == "" { + fp = cidToFp[r.ClientID] + } + apply(fp, func(e *visitEnrichment) { + e.RsvpCount++ + e.GuestNames = appendUniqueName(e.GuestNames, r.Name) + e.HasInteraction = true + }) + } + + var danmakus []models.Danmaku + if len(fps) > 0 || len(cids) > 0 { + q := db.Model(&models.Danmaku{}) + if len(fps) > 0 && len(cids) > 0 { + q = q.Where("fingerprint IN ? OR client_id IN ?", fps, cids) + } else if len(fps) > 0 { + q = q.Where("fingerprint IN ?", fps) + } else { + q = q.Where("client_id IN ?", cids) + } + q.Find(&danmakus) + } + for _, d := range danmakus { + fp := d.Fingerprint + if fp == "" { + fp = cidToFp[d.ClientID] + } + apply(fp, func(e *visitEnrichment) { + e.DanmakuCount++ + e.GuestNames = appendUniqueName(e.GuestNames, d.Name) + e.HasInteraction = true + }) + } + + return result +} + +func buildVisitListDB(q visitListQuery) *gorm.DB { + query := db.Model(&SiteVisit{}) + + if q.Region != "" { + if q.City != "" { + fps := fingerprintsForRegionCity(q.Region, q.City) + if len(fps) == 0 { + return query.Where("1 = 0") + } + query = query.Where("fingerprint IN ?", fps) + } else { + query = applyRegionWhere(query, q.Region) + } + } + + if q.HasGift { + fps := mapKeys(visitorFingerprintsWithGift()) + if len(fps) == 0 { + return query.Where("1 = 0") + } + query = query.Where("fingerprint IN ?", fps) + } + if q.HasInteraction { + fps := mapKeys(visitorFingerprintsWithInteraction()) + if len(fps) == 0 { + return query.Where("1 = 0") + } + query = query.Where("fingerprint IN ?", fps) + } + + return query +} + +func handleVisitList(c *gin.Context) { + if !requireAdmin(c) { + return + } + q := parseVisitListQuery(c) + + baseQuery := buildVisitListDB(q) var total int64 - db.Model(&SiteVisit{}).Count(&total) + baseQuery.Count(&total) var list []SiteVisit - db.Order("last_seen_at desc"). - Offset((page - 1) * pageSize). - Limit(pageSize). + baseQuery.Order(visitListOrder(q.Sort)). + Offset((q.Page - 1) * q.PageSize). + Limit(q.PageSize). Find(&list) if list == nil { list = []SiteVisit{} } + enrichMap := batchEnrichVisits(list) enriched := make([]gin.H, 0, len(list)) for _, row := range list { + e := enrichMap[row.Fingerprint] + guestNames := e.GuestNames + if guestNames == nil { + guestNames = []string{} + } 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, + "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, + "guest_names": guestNames, + "guest_name": strings.Join(guestNames, "、"), + "gift_count": e.GiftCount, + "like_count": e.LikeCount, + "rsvp_count": e.RsvpCount, + "danmaku_count": e.DanmakuCount, + "has_interaction": e.HasInteraction, }) } @@ -904,8 +1252,8 @@ func handleVisitList(c *gin.Context) { "data": gin.H{ "list": enriched, "total": total, - "page": page, - "pageSize": pageSize, + "page": q.Page, + "pageSize": q.PageSize, }, }) } diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index b0e4d01..9f40cc5 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -199,19 +199,40 @@
{{ selectedRegion }} · 市级分布 加载中… + 点击城市筛选下方访客
- - 关闭 + + 清除地区筛选 -
+
+ + +
+ 当前筛选: + + {{ selectedRegion }}{{ selectedCity ? ` · ${selectedCity}` : '' }} + + 有互动 + 有礼物
近期访客预览
- - 查看全部访客记录 - +
+ 有互动 + 有礼物 + + + 查看全部访客记录 + +
{{ shortFp(record.fingerprint) }} + + @@ -339,6 +366,24 @@ 刷新
+ +
+ 有互动 + 有礼物 + + +
+
点击行查看该访客的回执、弹幕、点赞与礼物详情
@@ -357,12 +402,21 @@ + + + @@ -2085,8 +2139,20 @@ let regionChart = null let deviceChart = null let cityChart = null const selectedRegion = ref('') +const selectedCity = ref('') const cityStats = ref([]) const cityStatsLoading = ref(false) +const visitFilter = reactive({ + hasInteraction: false, + hasGift: false, + sort: 'last_seen_desc', +}) +const visitSortOptions = [ + { label: '最近访问', value: 'last_seen_desc' }, + { label: '最早访问', value: 'last_seen_asc' }, + { label: '访问最多', value: 'visit_count_desc' }, + { label: '访问最少', value: 'visit_count_asc' }, +] const cfg = reactive(defaultConfig()) const uploadCfg = reactive(defaultUploadConfig()) @@ -3433,21 +3499,26 @@ async function onSave() { } const visitPreviewColumns = [ - { title: '指纹', key: 'fingerprint', width: 110 }, + { title: '指纹', key: 'fingerprint', width: 100 }, + { title: '姓名', key: 'guest_name', width: 90, ellipsis: true }, { title: '归属地', key: 'location', ellipsis: true }, - { title: '机型', key: 'device', width: 120, ellipsis: true }, - { title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 64 }, - { title: '最近访问', key: 'last_seen_at', width: 160 }, + { title: '机型', key: 'device', width: 110, ellipsis: true }, + { title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 56 }, + { title: '礼物', key: 'gift_count', width: 56 }, + { title: '最近访问', key: 'last_seen_at', width: 150 }, ] const visitColumns = [ - { title: '指纹', key: 'fingerprint', width: 120 }, - { title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 }, + { title: '指纹', key: 'fingerprint', width: 110 }, + { title: '姓名', key: 'guest_name', width: 100, ellipsis: true }, + { title: 'IP', dataIndex: 'ip', key: 'ip', width: 120 }, { title: '归属地', key: 'location', ellipsis: true }, - { title: '机型', key: 'device', width: 140, ellipsis: true }, - { title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 70 }, + { title: '机型', key: 'device', width: 130, ellipsis: true }, + { title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 64, sorter: true }, + { title: '礼物', key: 'gift_count', width: 64 }, + { title: '点赞', key: 'like_count', width: 64 }, { title: 'UA', key: 'user_agent', ellipsis: true }, - { title: '最近访问', key: 'last_seen_at', width: 170 }, + { title: '最近访问', key: 'last_seen_at', width: 160 }, ] function shortFp(fp) { @@ -3459,6 +3530,39 @@ function shortUa(ua) { return ua.length > 42 ? `${ua.slice(0, 42)}…` : ua } +function buildVisitListParams(extra = {}) { + const params = { + page: extra.page ?? visitPage.value, + pageSize: extra.pageSize ?? visitPageSize.value, + sort: visitFilter.sort, + } + if (selectedRegion.value) params.region = selectedRegion.value + if (selectedCity.value) params.city = selectedCity.value + if (visitFilter.hasInteraction) params.has_interaction = '1' + if (visitFilter.hasGift) params.has_gift = '1' + return params +} + +function formatGuestName(record) { + if (record.guest_name) return record.guest_name + const names = Array.isArray(record.guest_names) ? record.guest_names : [] + return names.length ? names.join('、') : '—' +} + +function onVisitFilterChange() { + visitPage.value = 1 + loadVisitList() + if (activeTab.value === 'overview') loadVisitPreview() +} + +function clearVisitRegionFilter() { + clearSelectedRegion() + selectedCity.value = '' + visitPage.value = 1 + loadVisitList() + if (activeTab.value === 'overview') loadVisitPreview() +} + const visitTableCustomRow = (record) => ({ onClick: () => openVisitDetail(record?.fingerprint), style: { cursor: 'pointer' }, @@ -3577,28 +3681,53 @@ function renderCityChart() { cityChart, Array.isArray(cityStats.value) ? cityStats.value : [], '暂无市级数据', + { selectable: true, selectedName: selectedCity.value }, ) + if (!cityChart) return + cityChart.off('click') + cityChart.on('click', (params) => { + const name = params?.name + if (!name || name === '暂无市级数据') return + if (selectedCity.value === name) { + selectedCity.value = '' + } else { + selectedCity.value = name + } + renderCityChart() + visitPage.value = 1 + loadVisitList() + if (activeTab.value === 'overview') loadVisitPreview() + }) } function clearSelectedRegion(opts = {}) { selectedRegion.value = '' + selectedCity.value = '' cityStats.value = [] disposeCityChart() - if (!opts.silent && regionChart) renderRegionChart() + if (!opts.silent) { + if (regionChart) renderRegionChart() + visitPage.value = 1 + loadVisitList() + if (activeTab.value === 'overview') loadVisitPreview() + } } async function onSelectRegion(name) { selectedRegion.value = name + selectedCity.value = '' cityStatsLoading.value = true cityStats.value = [] disposeCityChart() renderRegionChart() + visitPage.value = 1 try { const res = await getVisitCityStats(name) const d = res.data || {} cityStats.value = Array.isArray(d.cities) ? d.cities : [] await nextTick() renderCityChart() + await Promise.all([loadVisitPreview(), loadVisitList()]) } catch (e) { message.error(e?.message || '加载市级分布失败') cityStats.value = [] @@ -3684,7 +3813,7 @@ async function loadVisitStats() { async function loadVisitPreview() { visitListLoading.value = true try { - const listRes = await getVisitList({ page: 1, pageSize: 10 }) + const listRes = await getVisitList(buildVisitListParams({ page: 1, pageSize: 10 })) const listData = listRes.data || {} visitPreviewList.value = listData.list || [] visitTotal.value = Number(listData.total) || 0 @@ -3698,10 +3827,7 @@ async function loadVisitPreview() { async function loadVisitList() { visitListLoading.value = true try { - const listRes = await getVisitList({ - page: visitPage.value, - pageSize: visitPageSize.value, - }) + const listRes = await getVisitList(buildVisitListParams()) const listData = listRes.data || {} visitList.value = listData.list || [] visitTotal.value = Number(listData.total) || 0 @@ -3716,16 +3842,18 @@ async function loadOverview() { await Promise.all([loadVisitStats(), loadVisitPreview()]) } -function onVisitTableChange(pag) { +function onVisitTableChange(pag, _filters, sorter) { visitPage.value = pag?.current || 1 visitPageSize.value = pag?.pageSize || visitPageSize.value + if (sorter?.field === 'visit_count' || sorter?.columnKey === 'visit_count') { + visitFilter.sort = sorter.order === 'ascend' ? 'visit_count_asc' : 'visit_count_desc' + } loadVisitList() } async function onTabActivate(tab) { if (!adminStore.isAdmin) return if (tab !== 'overview') { - clearSelectedRegion({ silent: true }) disposeVisitCharts() } if (tab === 'overview') {