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