1. 海报

This commit is contained in:
李琦
2026-08-03 10:27:56 +08:00
parent 2d5fbbe62a
commit 76b894c446
2 changed files with 338 additions and 66 deletions

View File

@@ -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,

View File

@@ -54,9 +54,9 @@
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">访客概览</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">指纹去重访客与地区分布</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">指纹去重访客 · 地区 / 机型画像</div>
</div>
<a-button size="small" :loading="visitLoading" @click="loadOverview">
<a-button size="small" :loading="visitStatsLoading" @click="loadVisitStats">
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
</a-button>
</div>
@@ -82,41 +82,90 @@
<div ref="regionChartRef" class="h-[280px] w-full"></div>
</div>
<div class="chart-card">
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">说明</div>
<ul class="text-[12px] text-[#8A8680] leading-relaxed space-y-2 m-0 pl-4">
<li>进入请柬页会采集浏览器指纹与 IP服务端解析归属地</li>
<li>同一指纹 24 小时内重复访问会累加次数不新增访客</li>
<li>切到本页会重新拉取最新统计与列表</li>
</ul>
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">机型画像</div>
<div ref="deviceChartRef" class="h-[280px] w-full"></div>
</div>
</div>
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">近期访客</div>
<a-table
class="admin-table"
:columns="visitColumns"
:data-source="visitList"
:loading="visitLoading"
row-key="id"
size="middle"
:pagination="visitPagination"
@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>
<div class="flex justify-between items-center mb-2 gap-2 flex-wrap">
<div class="text-[12px] text-[#7a6550] tracking-wide">近期访客预览</div>
<a-button size="small" type="link" class="!text-[#a88c6b] !px-0" @click="activeTab = 'visits'">
查看全部访客记录 <i class="fa-solid fa-arrow-right ml-1 text-[10px]"></i>
</a-button>
</div>
<div class="visit-preview-wrap">
<a-table
class="admin-table"
:columns="visitPreviewColumns"
:data-source="visitPreviewList"
:loading="visitListLoading"
row-key="id"
size="small"
:pagination="false"
:scroll="{ y: 260 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fingerprint'">
<span class="font-mono text-[11px]" :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>
</template>
<template v-else-if="column.key === 'device'">
<span class="text-[12px]">{{ record.device || '未知' }}</span>
</template>
<template v-else-if="column.key === 'last_seen_at'">
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
</template>
</template>
<template v-else-if="column.key === 'location'">
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
</a-table>
</div>
</div>
</a-tab-pane>
<!-- 访客记录 -->
<a-tab-pane key="visits" tab="访客记录">
<div v-if="activeTab === 'visits'" class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">访客记录</div>
<div class="text-[11px] text-[#8A8680] mt-0.5"> {{ visitTotal }} 分页浏览</div>
</div>
<a-button size="small" :loading="visitListLoading" @click="loadVisitList">
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
</a-button>
</div>
<div class="visit-list-wrap">
<a-table
class="admin-table"
:columns="visitColumns"
:data-source="visitList"
:loading="visitListLoading"
row-key="id"
size="middle"
:pagination="visitPagination"
:scroll="{ y: 'calc(100dvh - 280px)', x: 960 }"
@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>
</template>
<template v-else-if="column.key === 'location'">
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
</template>
<template v-else-if="column.key === 'device'">
<span class="text-[12px]" :title="record.user_agent">{{ record.device || '未知' }}</span>
</template>
<template v-else-if="column.key === 'user_agent'">
<span class="text-[11px] text-[#8A8680]" :title="record.user_agent">{{ shortUa(record.user_agent) }}</span>
</template>
<template v-else-if="column.key === 'last_seen_at'">
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
</template>
</template>
<template v-else-if="column.key === 'user_agent'">
<span class="text-[11px] text-[#8A8680]" :title="record.user_agent">{{ shortUa(record.user_agent) }}</span>
</template>
<template v-else-if="column.key === 'last_seen_at'">
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
</template>
</template>
</a-table>
</a-table>
</div>
</div>
</a-tab-pane>
@@ -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()
})
</script>
@@ -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;