feat: add frontend analytics tracking
This commit is contained in:
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useAppStore } from './stores/app.js'
|
||||
import { logRuntimeEnv } from './services/request.js'
|
||||
import analytics from './services/analytics.js'
|
||||
|
||||
const statusBarHeight = ref(0)
|
||||
const safeAreaTop = ref(0)
|
||||
@@ -10,6 +11,7 @@ const safeAreaBottom = ref(0)
|
||||
|
||||
onLaunch(async () => {
|
||||
console.log('App Launch')
|
||||
analytics.initAnalytics()
|
||||
logRuntimeEnv('app:onLaunch')
|
||||
const store = useAppStore()
|
||||
await store.initialize()
|
||||
@@ -25,10 +27,13 @@ onLaunch(async () => {
|
||||
|
||||
onShow(() => {
|
||||
console.log('App Show')
|
||||
analytics.track('app_show', {}, { eventType: 'app' })
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
console.log('App Hide')
|
||||
analytics.track('app_hide', {}, { eventType: 'app' })
|
||||
analytics.flush()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -115,11 +115,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import Markdown from '../../components/Markdown.vue'
|
||||
import analytics from '../../services/analytics.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const pagePath = '/pages/life-event/detail'
|
||||
const safeAreaBottom = ref(0)
|
||||
const eventId = ref('')
|
||||
const cachedEvent = ref(null)
|
||||
@@ -134,6 +136,14 @@ onMounted(async () => {
|
||||
cachedEvent.value = uni.getStorageSync('current_life_event') || null
|
||||
if (!store.events?.length) await store.fetchEvents()
|
||||
isFavorite.value = Boolean(uni.getStorageSync(`event_favorite_${eventId.value}`))
|
||||
analytics.trackPageView(pagePath, {
|
||||
life_event_id: eventId.value,
|
||||
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
analytics.trackPageLeave(pagePath, { life_event_id: eventId.value })
|
||||
})
|
||||
|
||||
const displayEvent = computed(() => store.getEventById(eventId.value) || cachedEvent.value || {})
|
||||
@@ -284,6 +294,10 @@ const toggleFavorite = async () => {
|
||||
isFavorite.value = next
|
||||
if (next) uni.setStorageSync(`event_favorite_${eventId.value}`, '1')
|
||||
else uni.removeStorageSync(`event_favorite_${eventId.value}`)
|
||||
analytics.track('life_event_favorite', {
|
||||
life_event_id: eventId.value,
|
||||
favorite: next
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.showToast({ title: next ? '已收藏' : '已取消收藏', icon: 'success' })
|
||||
}
|
||||
|
||||
@@ -310,6 +324,10 @@ const shareCurrent = async () => {
|
||||
uni.showToast({ title: result.error || '分享失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
analytics.track('life_event_share', {
|
||||
life_event_id: eventId.value,
|
||||
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.setClipboardData({
|
||||
data: result.data?.shareText || `分享我的人生轨迹:${displayEvent.value.title || ''}`,
|
||||
success: () => uni.showToast({ title: '分享文案已复制', icon: 'success' })
|
||||
|
||||
@@ -167,8 +167,10 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import analytics from '../../services/analytics.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const pagePath = '/pages/life-event/form'
|
||||
const safeAreaTop = ref(20)
|
||||
const saving = ref(false)
|
||||
const currentYear = new Date().getFullYear()
|
||||
@@ -346,6 +348,9 @@ const assistWrite = async () => {
|
||||
if (!form.tags.includes(tag)) form.tags.push(tag)
|
||||
})
|
||||
}
|
||||
analytics.track('life_event_ai_assist', {
|
||||
tag_count: Array.isArray(form.tags) ? form.tags.length : 0
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.showToast({ title: result.data?.placeholder ? 'AI 占位内容已生成' : 'AI 已帮你优化', icon: 'none' })
|
||||
}
|
||||
|
||||
@@ -359,6 +364,10 @@ const submit = async () => {
|
||||
const result = form.id ? await store.updateEvent(payload) : await store.createEvent(payload)
|
||||
saving.value = false
|
||||
if (result.success) {
|
||||
analytics.track(form.id ? 'life_event_update' : 'life_event_create', {
|
||||
tag_count: Array.isArray(form.tags) ? form.tags.length : 0,
|
||||
has_time: Boolean(form.time || form.date)
|
||||
}, { eventType: 'life_event', pagePath })
|
||||
uni.navigateBack()
|
||||
} else {
|
||||
uni.showToast({ title: result.error || '保存失败', icon: 'none' })
|
||||
|
||||
@@ -59,9 +59,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import Markdown from '../../components/Markdown.vue'
|
||||
import analytics from '../../services/analytics.js'
|
||||
import ScriptAudioPlayer from '../../components/ScriptAudioPlayer.vue'
|
||||
|
||||
const store = useAppStore()
|
||||
@@ -69,6 +70,7 @@ const statusBarHeight = ref(20)
|
||||
const activeTab = ref('content')
|
||||
const scriptId = ref('')
|
||||
const script = ref(null)
|
||||
const pagePath = '/pages/main/ScriptDetailView'
|
||||
|
||||
const fullContent = computed(() => script.value?.content || '暂无正文内容。')
|
||||
const lengthText = computed(() => {
|
||||
@@ -109,6 +111,11 @@ const loadScript = async () => {
|
||||
|
||||
const selectCurrent = async () => {
|
||||
if (!script.value?.id) return
|
||||
analytics.track('path_select', {
|
||||
script_id: script.value.id,
|
||||
style: script.value.style || '',
|
||||
length: script.value.length || ''
|
||||
}, { eventType: 'script', pagePath })
|
||||
const res = await store.selectScript(script.value.id)
|
||||
if (!res.success) {
|
||||
uni.showToast({ title: res.error || '映射失败', icon: 'none' })
|
||||
@@ -126,6 +133,17 @@ onMounted(async () => {
|
||||
const pages = getCurrentPages()
|
||||
scriptId.value = pages[pages.length - 1]?.options?.id || ''
|
||||
await loadScript()
|
||||
analytics.trackPageView(pagePath, { script_id: scriptId.value })
|
||||
analytics.track('script_detail_view', {
|
||||
script_id: scriptId.value,
|
||||
style: script.value?.style || '',
|
||||
length: script.value?.length || '',
|
||||
word_count: script.value?.wordCount || 0
|
||||
}, { eventType: 'script', pagePath })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
analytics.trackPageLeave(pagePath, { script_id: scriptId.value })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
<view v-if="mode !== 'custom'" class="recent-section">
|
||||
<view class="section-line">
|
||||
<text class="section-title">最近生成</text>
|
||||
<text class="refresh" @click="mode = 'list'">查看全部 ›</text>
|
||||
<text class="refresh" @click="mode = 'list'">查看全部 →</text>
|
||||
</view>
|
||||
<view v-if="scripts.length === 0" class="empty-panel kos-card">
|
||||
<text>还没有剧本。先用一句灵感生成第一段平行人生。</text>
|
||||
@@ -181,8 +181,10 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
import analytics from '../../services/analytics.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const pagePath = '/pages/main/ScriptView'
|
||||
const mode = ref('inspiration')
|
||||
const prompt = ref('')
|
||||
const generating = ref(false)
|
||||
@@ -191,10 +193,10 @@ const style = ref('career')
|
||||
const randomRecommendations = ref([])
|
||||
const promptPlaceholder = [
|
||||
'例如:',
|
||||
'“如果我没有分手,现在会怎样?”',
|
||||
'“我成为顶级作曲家的人生”',
|
||||
'“重生回18岁改变一切”',
|
||||
'“从小镇做题家到世界首富”'
|
||||
'如果我没有分手,现在会怎样?',
|
||||
'我成为顶级作曲家的人生',
|
||||
'重生回18岁改变一切',
|
||||
'从小镇做题家到世界首富'
|
||||
].join('\n')
|
||||
const custom = reactive({
|
||||
theme: '',
|
||||
@@ -249,17 +251,28 @@ const lengthText = (value) => {
|
||||
}
|
||||
|
||||
const useRecommendation = (text) => {
|
||||
analytics.track('script_inspiration_click', {
|
||||
source: 'recommendation'
|
||||
}, { eventType: 'script', pagePath })
|
||||
prompt.value = text
|
||||
mode.value = 'inspiration'
|
||||
}
|
||||
|
||||
const shuffleInspirations = async () => {
|
||||
analytics.track('script_inspiration_view', {
|
||||
source: 'shuffle'
|
||||
}, { eventType: 'script', pagePath })
|
||||
const list = await store.fetchRandomInspirations(4)
|
||||
randomRecommendations.value = list.length ? list : fallbackRecommendations
|
||||
}
|
||||
|
||||
const generateByPrompt = async () => {
|
||||
if (!prompt.value.trim()) return
|
||||
analytics.track('script_generate_start', {
|
||||
style: style.value,
|
||||
length: 'medium',
|
||||
source: 'inspiration'
|
||||
}, { eventType: 'script', pagePath })
|
||||
generating.value = true
|
||||
const res = await store.generateScriptFromInspiration({
|
||||
prompt: prompt.value.trim(),
|
||||
@@ -269,10 +282,19 @@ const generateByPrompt = async () => {
|
||||
generating.value = false
|
||||
|
||||
if (!res.success) {
|
||||
analytics.track('script_generate_fail', {
|
||||
style: style.value,
|
||||
length: 'medium',
|
||||
error: res.error || 'unknown'
|
||||
}, { eventType: 'script', pagePath })
|
||||
uni.showToast({ title: res.error || '生成失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
analytics.track('script_generate_success', {
|
||||
style: style.value,
|
||||
length: 'medium'
|
||||
}, { eventType: 'script', pagePath })
|
||||
prompt.value = ''
|
||||
if (typeof res.data?.remainingCount === 'number') remainingCount.value = res.data.remainingCount
|
||||
mode.value = 'list'
|
||||
@@ -281,6 +303,11 @@ const generateByPrompt = async () => {
|
||||
|
||||
const generateCustom = async () => {
|
||||
if (!custom.theme.trim()) return
|
||||
analytics.track('script_generate_start', {
|
||||
style: custom.style,
|
||||
length: custom.length,
|
||||
source: 'custom'
|
||||
}, { eventType: 'script', pagePath })
|
||||
generating.value = true
|
||||
const res = await store.createScript({
|
||||
title: custom.theme,
|
||||
@@ -292,10 +319,19 @@ const generateCustom = async () => {
|
||||
generating.value = false
|
||||
|
||||
if (!res.success) {
|
||||
analytics.track('script_generate_fail', {
|
||||
style: custom.style,
|
||||
length: custom.length,
|
||||
error: res.error || 'unknown'
|
||||
}, { eventType: 'script', pagePath })
|
||||
uni.showToast({ title: res.error || '生成失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
analytics.track('script_generate_success', {
|
||||
style: custom.style,
|
||||
length: custom.length
|
||||
}, { eventType: 'script', pagePath })
|
||||
mode.value = 'list'
|
||||
}
|
||||
|
||||
@@ -305,6 +341,12 @@ const viewScriptDetail = (script) => {
|
||||
}
|
||||
|
||||
const selectScript = async (id) => {
|
||||
const selected = scripts.value.find(item => item.id === id)
|
||||
analytics.track('path_select', {
|
||||
script_id: id,
|
||||
style: selected?.style || '',
|
||||
length: selected?.length || ''
|
||||
}, { eventType: 'script', pagePath })
|
||||
const res = await store.selectScript(id)
|
||||
if (!res.success) {
|
||||
uni.showToast({ title: res.error || '映射失败', icon: 'none' })
|
||||
@@ -327,7 +369,6 @@ const handleVoiceInput = () => {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.script-view {
|
||||
display: flex;
|
||||
|
||||
@@ -53,14 +53,19 @@ import RecordView from './RecordView.vue'
|
||||
import ScriptView from './ScriptView.vue'
|
||||
import MineView from './MineView.vue'
|
||||
import MusicPlayer from '../../components/MusicPlayer.vue'
|
||||
import analytics from '../../services/analytics.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const activeTab = ref('record')
|
||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
||||
const safeAreaBottom = ref(uni.getStorageSync('safeAreaBottom') || 0)
|
||||
const pagePath = '/pages/main/index'
|
||||
|
||||
const switchTab = (tab) => {
|
||||
if (activeTab.value === tab) return
|
||||
analytics.track('page_leave', { tab: activeTab.value }, { eventType: 'page', pagePath })
|
||||
activeTab.value = tab
|
||||
analytics.track('page_view', { tab }, { eventType: 'page', pagePath })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -75,6 +80,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
uni.$on('switchTab', switchTab)
|
||||
analytics.trackPageView(pagePath, { tab: activeTab.value })
|
||||
await Promise.all([
|
||||
store.fetchUserProfile(),
|
||||
store.fetchEvents(),
|
||||
@@ -85,6 +91,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
analytics.trackPageLeave(pagePath, { tab: activeTab.value })
|
||||
uni.$off('switchTab', switchTab)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { post } from './request.js'
|
||||
|
||||
const QUEUE_KEY = 'analytics_queue'
|
||||
const ANON_KEY = 'analytics_anonymous_id'
|
||||
const MAX_QUEUE = 100
|
||||
const FLUSH_SIZE = 20
|
||||
const FLUSH_INTERVAL = 10000
|
||||
|
||||
let queue = []
|
||||
let sessionId = ''
|
||||
let pageEnterAt = {}
|
||||
let flushTimer = null
|
||||
let initialized = false
|
||||
|
||||
const now = () => Date.now()
|
||||
|
||||
const uuid = (prefix) => `${prefix}_${now()}_${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
export const getAnonymousId = () => {
|
||||
let id = uni.getStorageSync(ANON_KEY)
|
||||
if (!id) {
|
||||
id = uuid('anon')
|
||||
uni.setStorageSync(ANON_KEY, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
const loadQueue = () => {
|
||||
const saved = uni.getStorageSync(QUEUE_KEY)
|
||||
queue = Array.isArray(saved) ? saved.slice(-MAX_QUEUE) : []
|
||||
}
|
||||
|
||||
const saveQueue = () => {
|
||||
uni.setStorageSync(QUEUE_KEY, queue.slice(-MAX_QUEUE))
|
||||
}
|
||||
|
||||
const getDeviceInfo = () => {
|
||||
try {
|
||||
const system = uni.getSystemInfoSync()
|
||||
return {
|
||||
platform: system.platform,
|
||||
model: system.model,
|
||||
system: system.system,
|
||||
version: system.version,
|
||||
SDKVersion: system.SDKVersion
|
||||
}
|
||||
} catch (error) {
|
||||
return { platform: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
const safeProperties = (properties = {}) => {
|
||||
const blocked = ['token', 'access_token', 'refresh_token', 'phone', 'smsCode', 'content', 'fullContent']
|
||||
return Object.keys(properties || {}).reduce((acc, key) => {
|
||||
if (!blocked.includes(key)) acc[key] = properties[key]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const initAnalytics = () => {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
sessionId = uuid('session')
|
||||
loadQueue()
|
||||
track('app_launch', {}, { eventType: 'app' })
|
||||
if (!flushTimer) {
|
||||
flushTimer = setInterval(() => flush(), FLUSH_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
export const track = (eventName, properties = {}, options = {}) => {
|
||||
if (!eventName) return
|
||||
queue.push({
|
||||
eventName,
|
||||
eventType: options.eventType || eventName.split('_')[0] || 'custom',
|
||||
pagePath: options.pagePath || '',
|
||||
referrerPath: options.referrerPath || '',
|
||||
properties: safeProperties(properties),
|
||||
durationMs: options.durationMs,
|
||||
occurredAt: new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
})
|
||||
queue = queue.slice(-MAX_QUEUE)
|
||||
saveQueue()
|
||||
if (queue.length >= FLUSH_SIZE) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
export const trackPageView = (pagePath, properties = {}) => {
|
||||
pageEnterAt[pagePath] = now()
|
||||
track('page_view', properties, { eventType: 'page', pagePath })
|
||||
}
|
||||
|
||||
export const trackPageLeave = (pagePath, properties = {}) => {
|
||||
const durationMs = pageEnterAt[pagePath] ? now() - pageEnterAt[pagePath] : undefined
|
||||
delete pageEnterAt[pagePath]
|
||||
track('page_leave', properties, { eventType: 'page', pagePath, durationMs })
|
||||
}
|
||||
|
||||
export const flush = async () => {
|
||||
if (!queue.length || !sessionId) return
|
||||
const sending = queue.slice(0, 50)
|
||||
try {
|
||||
await post('/analytics/events/batch', {
|
||||
anonymousId: getAnonymousId(),
|
||||
sessionId,
|
||||
deviceInfo: getDeviceInfo(),
|
||||
events: sending
|
||||
})
|
||||
queue = queue.slice(sending.length)
|
||||
saveQueue()
|
||||
} catch (error) {
|
||||
saveQueue()
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
initAnalytics,
|
||||
track,
|
||||
trackPageView,
|
||||
trackPageLeave,
|
||||
flush,
|
||||
getAnonymousId
|
||||
}
|
||||
@@ -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