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
+5
View File
@@ -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>
+19 -1
View File
@@ -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>
+47 -6
View File
@@ -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;
+7
View File
@@ -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>
+124
View File
@@ -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
}