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 @@
-
说明
-
- - 进入请柬页会采集浏览器指纹与 IP,服务端解析归属地。
- - 同一指纹 24 小时内重复访问会累加次数,不新增访客。
- - 切到本页会重新拉取最新统计与列表。
-
+
机型画像
+
- 近期访客
-
-
-
- {{ shortFp(record.fingerprint) }}
+
+
近期访客预览
+
+ 查看全部访客记录
+
+
+
+
+
+
+ {{ shortFp(record.fingerprint) }}
+
+
+ {{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}
+
+
+ {{ record.device || '未知' }}
+
+
+ {{ formatTime(record.last_seen_at) }}
+
-
- {{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}
+
+
+
+
+
+
+
+
+
+
+
访客记录
+
共 {{ visitTotal }} 条,分页浏览
+
+
+ 刷新
+
+
+
+
+
+
+ {{ shortFp(record.fingerprint) }}
+
+
+ {{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}
+
+
+ {{ record.device || '未知' }}
+
+
+ {{ shortUa(record.user_agent) }}
+
+
+ {{ formatTime(record.last_seen_at) }}
+
-
- {{ shortUa(record.user_agent) }}
-
-
- {{ formatTime(record.last_seen_at) }}
-
-
-
+
+
@@ -937,7 +986,7 @@ const adminStore = useAdminStore()
const TAB_KEY = 'wedding_admin_active_tab'
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
-const VALID_TABS = ['overview', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku']
+const VALID_TABS = ['overview', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku']
const SECTION_KEYS = {
basic: ['groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'endImg'],
@@ -975,13 +1024,18 @@ const danmakuLoading = ref(false)
const danmakuList = ref([])
const danmakuFilter = ref('pending')
-const visitLoading = ref(false)
-const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [] })
+const visitStatsLoading = ref(false)
+const visitListLoading = ref(false)
+const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [], devices: [] })
const visitList = ref([])
+const visitPreviewList = ref([])
const visitPage = ref(1)
+const visitPageSize = ref(20)
const visitTotal = ref(0)
const regionChartRef = ref(null)
+const deviceChartRef = ref(null)
let regionChart = null
+let deviceChart = null
const cfg = reactive(defaultConfig())
const uploadCfg = reactive(defaultUploadConfig())
@@ -1026,10 +1080,11 @@ const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) |
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
const visitPagination = computed(() => ({
current: visitPage.value,
- pageSize: 20,
+ pageSize: visitPageSize.value,
total: visitTotal.value,
- hideOnSinglePage: true,
- showSizeChanger: false,
+ showSizeChanger: true,
+ pageSizeOptions: ['10', '20', '50', '100'],
+ showTotal: (t) => `共 ${t} 条`,
}))
function defaultConfig() {
@@ -1554,10 +1609,19 @@ async function onSave() {
}
}
+const visitPreviewColumns = [
+ { title: '指纹', key: 'fingerprint', width: 110 },
+ { 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 },
+]
+
const visitColumns = [
{ title: '指纹', key: 'fingerprint', width: 120 },
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
{ title: '归属地', key: 'location', ellipsis: true },
+ { title: '机型', key: 'device', width: 140, ellipsis: true },
{ title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 70 },
{ title: 'UA', key: 'user_agent', ellipsis: true },
{ title: '最近访问', key: 'last_seen_at', width: 170 },
@@ -1572,25 +1636,31 @@ function shortUa(ua) {
return ua.length > 42 ? `${ua.slice(0, 42)}…` : ua
}
-function disposeRegionChart() {
+const PIE_COLORS = ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b']
+
+function disposeVisitCharts() {
if (regionChart) {
regionChart.dispose()
regionChart = null
}
+ if (deviceChart) {
+ deviceChart.dispose()
+ deviceChart = null
+ }
}
-function renderRegionChart() {
- if (!regionChartRef.value) return
- if (regionChart && regionChart.getDom() !== regionChartRef.value) {
- disposeRegionChart()
+function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据') {
+ if (!chartRef.value) return null
+ if (holder && holder.getDom() !== chartRef.value) {
+ holder.dispose()
+ holder = null
}
- if (!regionChart) regionChart = echarts.init(regionChartRef.value)
- const regions = Array.isArray(visitStats.regions) ? visitStats.regions : []
- const data = regions.length
- ? regions.map((r) => ({ name: r.name || '未知', value: Number(r.value) || 0 }))
- : [{ name: '暂无数据', value: 0 }]
- regionChart.setOption({
- color: ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b'],
+ if (!holder) holder = echarts.init(chartRef.value)
+ const data = seriesData.length
+ ? seriesData.map((r) => ({ name: r.name || '未知', value: Number(r.value) || 0 }))
+ : [{ name: emptyName, value: 0 }]
+ holder.setOption({
+ color: PIE_COLORS,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: {
type: 'scroll',
@@ -1606,45 +1676,97 @@ function renderRegionChart() {
data,
}],
})
+ return holder
}
-async function loadOverview() {
- visitLoading.value = true
+function renderRegionChart() {
+ regionChart = renderPieChart(
+ regionChartRef,
+ regionChart,
+ Array.isArray(visitStats.regions) ? visitStats.regions : [],
+ )
+}
+
+function renderDeviceChart() {
+ deviceChart = renderPieChart(
+ deviceChartRef,
+ deviceChart,
+ Array.isArray(visitStats.devices) ? visitStats.devices : [],
+ )
+}
+
+async function loadVisitStats() {
+ visitStatsLoading.value = true
try {
- const [statsRes, listRes] = await Promise.all([
- getVisitStats(),
- getVisitList({ page: visitPage.value, pageSize: 20 }),
- ])
+ const statsRes = await getVisitStats()
const s = statsRes.data || {}
visitStats.uv = Number(s.uv) || 0
visitStats.pv = Number(s.pv) || 0
visitStats.today_uv = Number(s.today_uv) || 0
visitStats.regions = Array.isArray(s.regions) ? s.regions : []
+ visitStats.devices = Array.isArray(s.devices) ? s.devices : []
+ await nextTick()
+ renderRegionChart()
+ renderDeviceChart()
+ } catch (e) {
+ message.error(e?.message || '加载访客统计失败')
+ } finally {
+ visitStatsLoading.value = false
+ }
+}
+
+async function loadVisitPreview() {
+ visitListLoading.value = true
+ try {
+ const listRes = await getVisitList({ page: 1, pageSize: 10 })
+ const listData = listRes.data || {}
+ visitPreviewList.value = listData.list || []
+ visitTotal.value = Number(listData.total) || 0
+ } catch (e) {
+ message.error(e?.message || '加载访客预览失败')
+ } finally {
+ visitListLoading.value = false
+ }
+}
+
+async function loadVisitList() {
+ visitListLoading.value = true
+ try {
+ const listRes = await getVisitList({
+ page: visitPage.value,
+ pageSize: visitPageSize.value,
+ })
const listData = listRes.data || {}
visitList.value = listData.list || []
visitTotal.value = Number(listData.total) || 0
- await nextTick()
- renderRegionChart()
} catch (e) {
- message.error(e?.message || '加载访客数据失败')
+ message.error(e?.message || '加载访客记录失败')
} finally {
- visitLoading.value = false
+ visitListLoading.value = false
}
}
+async function loadOverview() {
+ await Promise.all([loadVisitStats(), loadVisitPreview()])
+}
+
function onVisitTableChange(pag) {
visitPage.value = pag?.current || 1
- loadOverview()
+ visitPageSize.value = pag?.pageSize || visitPageSize.value
+ loadVisitList()
}
async function onTabActivate(tab) {
if (!adminStore.isAdmin) return
- if (tab !== 'overview') disposeRegionChart()
+ if (tab !== 'overview') disposeVisitCharts()
if (tab === 'overview') {
- visitPage.value = 1
await loadOverview()
return
}
+ if (tab === 'visits') {
+ await loadVisitList()
+ return
+ }
if (CONFIG_SECTIONS.includes(tab)) {
await loadConfigSection(tab)
return
@@ -1753,7 +1875,10 @@ watch(
},
)
-const onResize = () => { regionChart?.resize() }
+const onResize = () => {
+ regionChart?.resize()
+ deviceChart?.resize()
+}
onMounted(() => {
window.addEventListener('resize', onResize)
@@ -1761,7 +1886,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', onResize)
- disposeRegionChart()
+ disposeVisitCharts()
})
@@ -1804,6 +1929,17 @@ onUnmounted(() => {
letter-spacing: 0.02em;
line-height: 1.1;
}
+.visit-preview-wrap,
+.visit-list-wrap {
+ border: 1px solid rgba(168, 140, 107, 0.12);
+ border-radius: 12px;
+ overflow: hidden;
+ background: #fffdf9;
+}
+.visit-preview-wrap :deep(.ant-table-body),
+.visit-list-wrap :deep(.ant-table-body) {
+ max-height: inherit;
+}
.chart-card {
border-radius: 14px;
padding: 14px 16px;