From 17c04211bb5ebfba70b0c35a17b29a33e9f36316 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 18 Nov 2025 21:29:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20PostHog=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA=20+=20=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: 1. 首次访问追踪 (first_visit) - 记录用户来源(referrer、UTM参数) - 记录落地页 - 使用 localStorage 永久标记 2. 首次登录追踪 (first_login) - 区分首次登录和后续登录 - 按用户 ID 独立标记 - 用于计算新用户激活率 3. 登录/登出事件追踪 - 登录成功追踪 (user_logged_in) - 登出事件追踪 (user_logged_out,必须在 resetUser 之前) - 注册事件追踪 (user_registered) 4. 页面浏览时长追踪 (page_view_duration) - 路由切换时自动计算停留时长 - 页面关闭时发送最终时长 - 过滤停留时间 < 1秒的快速跳转 性能优化: 1. 新增 trackEventAsync 函数 - 使用 requestIdleCallback 在浏览器空闲时发送非关键事件 - Safari 等旧浏览器降级到 setTimeout - 超时保护(最多延迟 2秒) 2. 异步追踪非关键事件 - first_visit - 不阻塞首屏渲染 - page_view_duration - 不阻塞页面切换 3. 关键事件保持同步 - user_registered、user_logged_in、first_login、user_logged_out - 确保数据准确性和完整性 分析能力提升: - ✅ 营销渠道 ROI 分析(UTM 参数追踪) - ✅ 新用户激活率分析(首次登录标记) - ✅ 用户留存率分析(注册→首次登录→后续登录) - ✅ 页面热度分析(停留时长统计) - ✅ 流失用户识别(7天未登录,需后端支持) --- src/App.js | 76 ++++++++++++++++++++++++++++++++++++- src/contexts/AuthContext.js | 69 +++++++++++++++++++++++++++++++++ src/lib/posthog.js | 24 ++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/App.js b/src/App.js index e48d2c0e..927e6805 100755 --- a/src/App.js +++ b/src/App.js @@ -9,8 +9,9 @@ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware. */ -import React, { useEffect } from "react"; +import React, { useEffect, useRef } from "react"; import { useDispatch } from 'react-redux'; +import { useLocation } from 'react-router-dom'; // Routes import AppRoutes from './routes'; @@ -30,12 +31,24 @@ import { initializePostHog } from './store/slices/posthogSlice'; // Utils import { logger } from './utils/logger'; +// PostHog 追踪 +import { trackEvent, trackEventAsync } from '@lib/posthog'; + +// Contexts +import { useAuth } from '@contexts/AuthContext'; + /** * AppContent - 应用核心内容 * 负责 PostHog 初始化和渲染路由 */ function AppContent() { const dispatch = useDispatch(); + const location = useLocation(); + const { isAuthenticated } = useAuth(); + + // ✅ 使用 Ref 存储页面进入时间和路径(避免闭包问题) + const pageEnterTimeRef = useRef(Date.now()); + const currentPathRef = useRef(location.pathname); // 🎯 PostHog Redux 初始化 useEffect(() => { @@ -43,6 +56,67 @@ function AppContent() { logger.info('App', 'PostHog Redux 初始化已触发'); }, [dispatch]); + // ✅ 首次访问追踪 + useEffect(() => { + const hasVisited = localStorage.getItem('has_visited'); + + if (!hasVisited) { + const urlParams = new URLSearchParams(location.search); + + // ⚡ 使用异步追踪,不阻塞页面渲染 + trackEventAsync('first_visit', { + referrer: document.referrer || 'direct', + utm_source: urlParams.get('utm_source'), + utm_medium: urlParams.get('utm_medium'), + utm_campaign: urlParams.get('utm_campaign'), + landing_page: location.pathname, + timestamp: new Date().toISOString() + }); + + localStorage.setItem('has_visited', 'true'); + } + }, [location.search, location.pathname]); + + // ✅ 页面浏览时长追踪 + useEffect(() => { + // 计算上一个页面的停留时长 + const calculateAndTrackDuration = () => { + const exitTime = Date.now(); + const duration = Math.round((exitTime - pageEnterTimeRef.current) / 1000); // 秒 + + // 只追踪停留时间 > 1 秒的页面(过滤快速跳转) + if (duration > 1) { + // ⚡ 使用异步追踪,不阻塞页面切换 + trackEventAsync('page_view_duration', { + path: currentPathRef.current, + duration_seconds: duration, + is_authenticated: isAuthenticated, + timestamp: new Date().toISOString() + }); + } + }; + + // 路由切换时追踪上一个页面的时长 + if (currentPathRef.current !== location.pathname) { + calculateAndTrackDuration(); + + // 更新为新页面 + currentPathRef.current = location.pathname; + pageEnterTimeRef.current = Date.now(); + } + + // 页面关闭/刷新时追踪时长 + const handleBeforeUnload = () => { + calculateAndTrackDuration(); + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [location.pathname, isAuthenticated]); + return ; } diff --git a/src/contexts/AuthContext.js b/src/contexts/AuthContext.js index b1ae1879..45b40059 100755 --- a/src/contexts/AuthContext.js +++ b/src/contexts/AuthContext.js @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { useToast } from '@chakra-ui/react'; import { logger } from '../utils/logger'; import { useNotification } from '../contexts/NotificationContext'; +import { identifyUser, resetUser, trackEvent } from '@lib/posthog'; // 创建认证上下文 const AuthContext = createContext(); @@ -90,6 +91,16 @@ export const AuthProvider = ({ children }) => { if (prevUser && prevUser.id === data.user.id) { return prevUser; } + + // ✅ 识别用户身份到 PostHog + identifyUser(data.user.id, { + email: data.user.email, + username: data.user.username, + subscription_tier: data.user.subscription_tier, + role: data.user.role, + registration_date: data.user.created_at + }); + return data.user; }); setIsAuthenticated((prev) => prev === true ? prev : true); @@ -209,6 +220,26 @@ export const AuthProvider = ({ children }) => { setUser(data.user); setIsAuthenticated(true); + // ✅ 追踪登录事件 + trackEvent('user_logged_in', { + loginType, + timestamp: new Date().toISOString() + }); + + // ✅ 首次登录追踪 + const firstLoginKey = `first_login_${data.user.id}`; + const hasLoggedInBefore = localStorage.getItem(firstLoginKey); + + if (!hasLoggedInBefore) { + trackEvent('first_login', { + user_id: data.user.id, + login_type: loginType, + timestamp: new Date().toISOString() + }); + + localStorage.setItem(firstLoginKey, 'true'); + } + // ⚡ 移除toast,让调用者处理UI反馈,避免并发更新冲突 // toast({ // title: "登录成功", @@ -263,6 +294,21 @@ export const AuthProvider = ({ children }) => { setUser(data.user); setIsAuthenticated(true); + // ✅ 识别用户身份到 PostHog + identifyUser(data.user.id, { + email: data.user.email, + username: data.user.username, + subscription_tier: data.user.subscription_tier, + role: data.user.role, + registration_date: data.user.created_at + }); + + // ✅ 追踪注册事件 + trackEvent('user_registered', { + method: 'phone', + timestamp: new Date().toISOString() + }); + toast({ title: "注册成功", description: "欢迎加入价值前沿!", @@ -315,6 +361,21 @@ export const AuthProvider = ({ children }) => { setUser(data.user); setIsAuthenticated(true); + // ✅ 识别用户身份到 PostHog + identifyUser(data.user.id, { + email: data.user.email, + username: data.user.username, + subscription_tier: data.user.subscription_tier, + role: data.user.role, + registration_date: data.user.created_at + }); + + // ✅ 追踪注册事件 + trackEvent('user_registered', { + method: 'email', + timestamp: new Date().toISOString() + }); + toast({ title: "注册成功", description: "欢迎加入价值前沿!", @@ -405,6 +466,14 @@ export const AuthProvider = ({ children }) => { credentials: 'include' }); + // ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份) + trackEvent('user_logged_out', { + timestamp: new Date().toISOString() + }); + + // ✅ 重置 PostHog 用户会话 + resetUser(); + // 清除本地状态 setUser(null); setIsAuthenticated(false); diff --git a/src/lib/posthog.js b/src/lib/posthog.js index f6491769..af533f66 100644 --- a/src/lib/posthog.js +++ b/src/lib/posthog.js @@ -185,6 +185,30 @@ export const trackEvent = (eventName, properties = {}) => { } }; +/** + * 异步追踪事件(不阻塞主线程) + * 使用 requestIdleCallback 在浏览器空闲时发送事件 + * + * @param {string} eventName - 事件名称 + * @param {object} properties - 事件属性 + */ +export const trackEventAsync = (eventName, properties = {}) => { + // 浏览器支持 requestIdleCallback 时使用(推荐) + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback( + () => { + trackEvent(eventName, properties); + }, + { timeout: 2000 } // 最多延迟 2 秒(防止永远不执行) + ); + } else { + // 降级方案:使用 setTimeout(兼容性更好) + setTimeout(() => { + trackEvent(eventName, properties); + }, 0); + } +}; + /** * Track page view *