feat: add frontend analytics tracking
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
@@ -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[] = [
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user