feat: add frontend analytics tracking
This commit is contained in:
@@ -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