feat: add frontend analytics tracking

This commit is contained in:
2026-05-17 10:18:56 +08:00
parent 3decff526a
commit 1016111d19
11 changed files with 583 additions and 10 deletions
+79
View File
@@ -0,0 +1,79 @@
import request from '@/utils/request'
export interface AnalyticsOverview {
pv: number
uv: number
eventCount: number
activeUsers: number
ttsRequests: number
ttsPlays: number
avgStayMs: number
}
export interface AnalyticsTrendItem {
bucket: string
eventName: string
count: number
users: number
}
export interface AnalyticsTopEvent {
eventName: string
eventType: string
count: number
users: number
}
export interface AnalyticsFunnelItem {
eventName: string
label: string
users: number
conversionRate: number
}
export interface AnalyticsPreferenceItem {
dimension: string
value: string
count: number
users: number
}
export interface AnalyticsUserItem {
userId?: string
anonymousId?: string
eventCount: number
lastActiveTime?: string
}
export interface AnalyticsQuery {
startDate?: string
endDate?: string
granularity?: 'day' | 'hour'
eventNames?: string[]
eventType?: string
limit?: number
}
export function getAnalyticsOverview(params: AnalyticsQuery = {}) {
return request<AnalyticsOverview>({ url: '/admin/analytics/overview', method: 'get', params })
}
export function getAnalyticsTrend(params: AnalyticsQuery = {}) {
return request<AnalyticsTrendItem[]>({ url: '/admin/analytics/trend', method: 'get', params })
}
export function getAnalyticsTopEvents(params: AnalyticsQuery = {}) {
return request<AnalyticsTopEvent[]>({ url: '/admin/analytics/top-events', method: 'get', params })
}
export function getAnalyticsFunnel(params: AnalyticsQuery = {}) {
return request<AnalyticsFunnelItem[]>({ url: '/admin/analytics/funnel', method: 'get', params })
}
export function getAnalyticsPreferences(params: AnalyticsQuery = {}) {
return request<AnalyticsPreferenceItem[]>({ url: '/admin/analytics/preferences', method: 'get', params })
}
export function getAnalyticsUsers(params: AnalyticsQuery = {}) {
return request<AnalyticsUserItem[]>({ url: '/admin/analytics/users', method: 'get', params })
}
+6 -1
View File
@@ -12,6 +12,11 @@ export const menuConfig: MenuItem[] = [
title: '仪表盘',
icon: 'DataLine'
},
{
path: '/analytics',
title: '行为分析',
icon: 'TrendCharts'
},
{
path: '/admin',
title: '管理员管理',
@@ -43,4 +48,4 @@ export const menuConfig: MenuItem[] = [
}
]
}
]
]
+7 -1
View File
@@ -18,6 +18,12 @@ const routes: RouteRecordRaw[] = [
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘', icon: 'DataLine' }
},
{
path: 'analytics',
name: 'AnalyticsDashboard',
component: () => import('@/views/analytics/AnalyticsDashboard.vue'),
meta: { title: '行为分析', icon: 'TrendCharts' }
}
]
},
@@ -120,7 +126,7 @@ const router = createRouter({
// 路由守卫
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('adminToken')
if (to.path === '/login') {
if (token) {
next('/')
@@ -0,0 +1,261 @@
<template>
<div class="analytics-dashboard">
<div class="dashboard-header">
<div>
<h2 class="page-title">行为分析</h2>
<p class="page-subtitle">小程序访问创作链路和朗读行为概览</p>
</div>
<div class="header-actions">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
size="small"
/>
<el-button type="primary" size="small" :loading="loading" @click="fetchData">刷新</el-button>
</div>
</div>
<el-row :gutter="16" class="stats-row" v-loading="loading">
<el-col v-for="card in cards" :key="card.label" :xs="12" :sm="8" :md="4">
<el-card class="stat-card">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-label">{{ card.label }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="panel-row">
<el-col :xs="24" :md="14">
<el-card class="panel-card">
<template #header>事件趋势</template>
<div ref="trendChartRef" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card class="panel-card">
<template #header>关键漏斗</template>
<el-table :data="funnel" size="small" height="320">
<el-table-column prop="label" label="步骤" min-width="150" show-overflow-tooltip />
<el-table-column prop="users" label="用户" width="90" />
<el-table-column label="转化率" width="100">
<template #default="{ row }">{{ formatPercent(row.conversionRate) }}</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="panel-row">
<el-col :xs="24" :md="12">
<el-card class="panel-card">
<template #header>热门事件</template>
<el-table :data="topEvents" size="small" height="320">
<el-table-column prop="eventName" label="事件" min-width="170" show-overflow-tooltip />
<el-table-column prop="eventType" label="类型" width="110" />
<el-table-column prop="count" label="次数" width="95" />
<el-table-column prop="users" label="用户" width="95" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card class="panel-card">
<template #header>偏好分布</template>
<el-table :data="preferences" size="small" height="320">
<el-table-column prop="dimension" label="维度" width="110" show-overflow-tooltip />
<el-table-column prop="value" label="值" min-width="160" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="95" />
<el-table-column prop="users" label="用户" width="95" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import {
getAnalyticsFunnel,
getAnalyticsOverview,
getAnalyticsPreferences,
getAnalyticsTopEvents,
getAnalyticsTrend,
type AnalyticsFunnelItem,
type AnalyticsOverview,
type AnalyticsPreferenceItem,
type AnalyticsQuery,
type AnalyticsTopEvent,
type AnalyticsTrendItem
} from '@/api/analytics'
const loading = ref(false)
const dateRange = ref<[string, string] | ''>('')
const overview = ref<AnalyticsOverview>({
pv: 0,
uv: 0,
eventCount: 0,
activeUsers: 0,
ttsRequests: 0,
ttsPlays: 0,
avgStayMs: 0
})
const trend = ref<AnalyticsTrendItem[]>([])
const topEvents = ref<AnalyticsTopEvent[]>([])
const funnel = ref<AnalyticsFunnelItem[]>([])
const preferences = ref<AnalyticsPreferenceItem[]>([])
const trendChartRef = ref<HTMLDivElement>()
let trendChart: echarts.ECharts | null = null
const cards = computed(() => [
{ label: 'PV', value: overview.value.pv.toLocaleString() },
{ label: 'UV', value: overview.value.uv.toLocaleString() },
{ label: '事件数', value: overview.value.eventCount.toLocaleString() },
{ label: '活跃用户', value: overview.value.activeUsers.toLocaleString() },
{ label: '朗读请求', value: overview.value.ttsRequests.toLocaleString() },
{ label: '平均停留', value: `${Math.round((overview.value.avgStayMs || 0) / 1000)}s` }
])
const queryParams = computed<AnalyticsQuery>(() => {
if (!Array.isArray(dateRange.value)) return { granularity: 'day' }
return {
startDate: dateRange.value[0],
endDate: dateRange.value[1],
granularity: 'day'
}
})
const formatPercent = (value: number) => `${Math.round((value || 0) * 100)}%`
const responseData = <T,>(response: unknown, fallback: T): T => {
const payload = response as { data?: T }
return payload.data ?? fallback
}
const renderTrend = async () => {
await nextTick()
if (!trendChartRef.value) return
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
const buckets = [...new Set(trend.value.map(item => item.bucket))]
const eventNames = [...new Set(trend.value.map(item => item.eventName))].slice(0, 5)
const series = eventNames.map(eventName => ({
name: eventName,
type: 'line',
smooth: true,
data: buckets.map(bucket => trend.value.find(item => item.bucket === bucket && item.eventName === eventName)?.count || 0)
}))
trendChart.setOption({
color: ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399'],
tooltip: { trigger: 'axis' },
legend: { top: 0, textStyle: { color: '#94a3b8' } },
grid: { left: 36, right: 18, top: 48, bottom: 28 },
xAxis: { type: 'category', data: buckets, axisLabel: { color: '#94a3b8' } },
yAxis: { type: 'value', axisLabel: { color: '#94a3b8' }, splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.18)' } } },
series
})
}
const fetchData = async () => {
loading.value = true
try {
const params = queryParams.value
const [overviewRes, trendRes, topRes, funnelRes, preferenceRes] = await Promise.all([
getAnalyticsOverview(params),
getAnalyticsTrend(params),
getAnalyticsTopEvents({ ...params, limit: 20 }),
getAnalyticsFunnel(params),
getAnalyticsPreferences({ ...params, limit: 20 })
])
overview.value = responseData(overviewRes, overview.value)
trend.value = responseData(trendRes, [])
topEvents.value = responseData(topRes, [])
funnel.value = responseData(funnelRes, [])
preferences.value = responseData(preferenceRes, [])
await renderTrend()
} catch (error) {
ElMessage.error('行为分析数据加载失败')
} finally {
loading.value = false
}
}
const resizeChart = () => trendChart?.resize()
onMounted(() => {
fetchData()
window.addEventListener('resize', resizeChart)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
trendChart?.dispose()
})
</script>
<style scoped lang="scss">
.analytics-dashboard {
.dashboard-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.page-title {
margin: 0;
font-size: 24px;
color: var(--ls-text);
}
.page-subtitle {
margin: 6px 0 0;
color: rgba(226, 232, 240, 0.62);
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.stats-row,
.panel-row {
margin-bottom: 16px;
}
.stat-card {
min-height: 96px;
}
.stat-value {
color: var(--ls-text);
font-size: 25px;
font-weight: 700;
line-height: 1.2;
word-break: break-word;
}
.stat-label {
margin-top: 10px;
color: rgba(226, 232, 240, 0.65);
font-size: 13px;
}
.panel-card {
margin-bottom: 16px;
}
.chart {
height: 320px;
width: 100%;
}
}
</style>