Compare commits
8 Commits
542b20368e
...
c6a6444d9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6a6444d9a | ||
|
|
c42a14aa8f | ||
|
|
cddd0e860e | ||
|
|
fbe3434521 | ||
|
|
bca2ad4f81 | ||
|
|
8f3af4ed07 | ||
|
|
fb76e442f7 | ||
|
|
6506cb222b |
@@ -43,6 +43,7 @@
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.281.0",
|
||||
"react": "18.3.1",
|
||||
"react-apexcharts": "^1.3.9",
|
||||
"react-big-calendar": "^0.33.2",
|
||||
|
||||
11
src/App.js
11
src/App.js
@@ -61,6 +61,10 @@ import NotificationTestTool from "components/NotificationTestTool";
|
||||
import ScrollToTop from "components/ScrollToTop";
|
||||
import { logger } from "utils/logger";
|
||||
|
||||
// PostHog Redux 集成
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { initializePostHog } from "store/slices/posthogSlice";
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
* 需要在 NotificationProvider 内部使用,所以单独提取
|
||||
@@ -108,6 +112,13 @@ function ConnectionStatusBarWrapper() {
|
||||
|
||||
function AppContent() {
|
||||
const { colorMode } = useColorMode();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 🎯 PostHog Redux 初始化
|
||||
useEffect(() => {
|
||||
dispatch(initializePostHog());
|
||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||
|
||||
@@ -37,6 +37,7 @@ import VerificationCodeInput from './VerificationCodeInput';
|
||||
import WechatRegister from './WechatRegister';
|
||||
import { setCurrentUser } from '../../mocks/data/users';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useAuthEvents } from '../../hooks/useAuthEvents';
|
||||
|
||||
// 统一配置对象
|
||||
const AUTH_CONFIG = {
|
||||
@@ -86,6 +87,12 @@ export default function AuthFormContent() {
|
||||
|
||||
// 响应式布局配置
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'AuthFormContent',
|
||||
isMobile: isMobile
|
||||
});
|
||||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px,更紧凑
|
||||
|
||||
@@ -107,6 +114,16 @@ export default function AuthFormContent() {
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 追踪用户开始填写手机号 (判断用户选择了手机登录方式)
|
||||
if (name === 'phone' && value.length === 1 && !formData.phone) {
|
||||
authEvents.trackPhoneLoginInitiated(value);
|
||||
}
|
||||
|
||||
// 追踪验证码输入变化
|
||||
if (name === 'verificationCode') {
|
||||
authEvents.trackVerificationCodeInputChanged(value.length);
|
||||
}
|
||||
};
|
||||
|
||||
// 倒计时逻辑
|
||||
@@ -144,6 +161,10 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(credential)) {
|
||||
// 追踪手机号验证失败
|
||||
authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format');
|
||||
authEvents.trackFormValidationError('phone', 'invalid_format', '请输入有效的手机号');
|
||||
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -152,6 +173,9 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪手机号验证通过
|
||||
authEvents.trackPhoneNumberValidated(credential, true);
|
||||
|
||||
try {
|
||||
setSendingCode(true);
|
||||
|
||||
@@ -187,6 +211,14 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// 追踪验证码发送成功 (或重发)
|
||||
const isResend = verificationCodeSent;
|
||||
if (isResend) {
|
||||
authEvents.trackVerificationCodeResent(credential, countdown > 0 ? 2 : 1);
|
||||
} else {
|
||||
authEvents.trackVerificationCodeSent(credential, config.api.purpose);
|
||||
}
|
||||
|
||||
// ❌ 移除成功 toast,静默处理
|
||||
logger.info('AuthFormContent', '验证码发送成功', {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7),
|
||||
@@ -204,6 +236,13 @@ export default function AuthFormContent() {
|
||||
throw new Error(data.error || '发送验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 追踪验证码发送失败
|
||||
authEvents.trackVerificationCodeSendFailed(credential, error);
|
||||
authEvents.trackError('api', error.message || '发送验证码失败', {
|
||||
endpoint: '/api/auth/send-verification-code',
|
||||
phone_masked: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
});
|
||||
|
||||
logger.api.error('POST', '/api/auth/send-verification-code', error, {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
});
|
||||
@@ -256,6 +295,9 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪验证码提交
|
||||
authEvents.trackVerificationCodeSubmitted(phone);
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = {
|
||||
credential: phone.trim(), // 添加 trim() 防止空格
|
||||
@@ -310,6 +352,9 @@ export default function AuthFormContent() {
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
// 追踪登录成功并识别用户
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
|
||||
|
||||
// ✅ 保留登录成功 toast(关键操作提示)
|
||||
toast({
|
||||
title: data.isNewUser ? '注册成功' : '登录成功',
|
||||
@@ -329,6 +374,8 @@ export default function AuthFormContent() {
|
||||
setTimeout(() => {
|
||||
setCurrentPhone(phone);
|
||||
setShowNicknamePrompt(true);
|
||||
// 追踪昵称设置引导显示
|
||||
authEvents.trackNicknamePromptShown(phone);
|
||||
}, config.features.successDelay);
|
||||
} else {
|
||||
// 已有用户,直接登录成功
|
||||
@@ -349,6 +396,15 @@ export default function AuthFormContent() {
|
||||
}
|
||||
} catch (error) {
|
||||
const { phone, verificationCode } = formData;
|
||||
|
||||
// 追踪登录失败
|
||||
const errorType = error.message.includes('网络') ? 'network' :
|
||||
error.message.includes('服务器') ? 'api' : 'validation';
|
||||
authEvents.trackLoginFailed('phone', errorType, error.message, {
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
has_verification_code: !!verificationCode
|
||||
});
|
||||
|
||||
logger.error('AuthFormContent', 'handleSubmit', error, {
|
||||
phone: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
hasVerificationCode: !!verificationCode
|
||||
@@ -376,6 +432,9 @@ export default function AuthFormContent() {
|
||||
|
||||
// 微信H5登录处理
|
||||
const handleWechatH5Login = async () => {
|
||||
// 追踪用户选择微信登录
|
||||
authEvents.trackWechatLoginInitiated('icon_button');
|
||||
|
||||
try {
|
||||
// 1. 构建回调URL
|
||||
const redirectUrl = `${window.location.origin}/home/wechat-callback`;
|
||||
@@ -396,11 +455,19 @@ export default function AuthFormContent() {
|
||||
throw new Error('获取授权链接失败');
|
||||
}
|
||||
|
||||
// 追踪微信H5跳转
|
||||
authEvents.trackWechatH5Redirect();
|
||||
|
||||
// 4. 延迟跳转,让用户看到提示
|
||||
setTimeout(() => {
|
||||
window.location.href = response.auth_url;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
// 追踪跳转失败
|
||||
authEvents.trackError('api', error.message || '获取微信授权链接失败', {
|
||||
context: 'wechat_h5_redirect'
|
||||
});
|
||||
|
||||
logger.error('AuthFormContent', 'handleWechatH5Login', error);
|
||||
toast({
|
||||
title: "跳转失败",
|
||||
@@ -412,14 +479,17 @@ export default function AuthFormContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
// 组件挂载时追踪页面浏览
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 追踪登录页面浏览
|
||||
authEvents.trackLoginPageViewed();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
}, [authEvents]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -479,6 +549,7 @@ export default function AuthFormContent() {
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
onClick={authEvents.trackUserAgreementClicked}
|
||||
>
|
||||
《用户协议》
|
||||
</ChakraLink>
|
||||
@@ -491,6 +562,7 @@ export default function AuthFormContent() {
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
onClick={authEvents.trackPrivacyPolicyClicked}
|
||||
>
|
||||
《隐私政策》
|
||||
</ChakraLink>
|
||||
@@ -518,8 +590,30 @@ export default function AuthFormContent() {
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">完善个人信息</AlertDialogHeader>
|
||||
<AlertDialogBody>您已成功注册!是否前往个人资料设置昵称和其他信息?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }}>稍后再说</Button>
|
||||
<Button colorScheme="green" onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); setTimeout(() => { navigate('/home/profile'); }, 300); }} ml={3}>去设置</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptSkipped();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
}}
|
||||
>
|
||||
稍后再说
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptAccepted();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
setTimeout(() => {
|
||||
navigate('/home/profile');
|
||||
}, 300);
|
||||
}}
|
||||
ml={3}
|
||||
>
|
||||
去设置
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/auth
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { useAuthEvents } from "../../hooks/useAuthEvents";
|
||||
|
||||
// 配置常量
|
||||
const POLL_INTERVAL = 2000; // 轮询间隔:2秒
|
||||
@@ -51,6 +52,12 @@ export default function WechatRegister() {
|
||||
const { closeModal } = useAuthModal();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'WechatRegister',
|
||||
isMobile: false // WechatRegister 只在桌面端显示
|
||||
});
|
||||
|
||||
// 状态管理
|
||||
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
|
||||
const [wechatSessionId, setWechatSessionId] = useState("");
|
||||
@@ -126,6 +133,13 @@ export default function WechatRegister() {
|
||||
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
|
||||
|
||||
if (response?.success) {
|
||||
// 追踪微信登录成功
|
||||
authEvents.trackLoginSuccess(
|
||||
response.user,
|
||||
'wechat',
|
||||
response.isNewUser || false
|
||||
);
|
||||
|
||||
// Session cookie 会自动管理,不需要手动存储
|
||||
// 如果后端返回了 token,可以选择性存储(兼容旧方式)
|
||||
if (response.token) {
|
||||
@@ -148,10 +162,16 @@ export default function WechatRegister() {
|
||||
throw new Error(response?.error || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 追踪微信登录失败
|
||||
authEvents.trackLoginFailed('wechat', 'api', error.message || '登录失败', {
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
status: status
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
|
||||
showError("登录失败", error.message || "请重试");
|
||||
}
|
||||
}, [showSuccess, showError, closeModal, refreshSession]);
|
||||
}, [showSuccess, showError, closeModal, refreshSession, authEvents]);
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
@@ -191,6 +211,16 @@ export default function WechatRegister() {
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 追踪状态变化
|
||||
if (wechatStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, wechatStatus, status);
|
||||
|
||||
// 特别追踪扫码事件
|
||||
if (status === WECHAT_STATUS.SCANNED) {
|
||||
authEvents.trackWechatQRScanned(currentSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
setWechatStatus(status);
|
||||
|
||||
// 处理成功状态
|
||||
@@ -203,6 +233,9 @@ export default function WechatRegister() {
|
||||
}
|
||||
// 处理过期状态
|
||||
else if (status === WECHAT_STATUS.EXPIRED) {
|
||||
// 追踪二维码过期
|
||||
authEvents.trackWechatQRExpired(currentSessionId, QR_CODE_TIMEOUT / 1000);
|
||||
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
@@ -268,6 +301,16 @@ export default function WechatRegister() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 追踪用户选择微信登录(首次或刷新)
|
||||
const isRefresh = Boolean(wechatSessionId);
|
||||
if (isRefresh) {
|
||||
const oldSessionId = wechatSessionId;
|
||||
authEvents.trackWechatLoginInitiated('qr_refresh');
|
||||
// 稍后会在成功时追踪刷新事件
|
||||
} else {
|
||||
authEvents.trackWechatLoginInitiated('qr_area');
|
||||
}
|
||||
|
||||
// 生产环境:调用真实 API
|
||||
const response = await authService.getWechatQRCode();
|
||||
|
||||
@@ -283,6 +326,13 @@ export default function WechatRegister() {
|
||||
throw new Error(response.message || '获取二维码失败');
|
||||
}
|
||||
|
||||
// 追踪二维码显示 (首次或刷新)
|
||||
if (isRefresh) {
|
||||
authEvents.trackWechatQRRefreshed(wechatSessionId, response.data.session_id);
|
||||
} else {
|
||||
authEvents.trackWechatQRDisplayed(response.data.session_id, response.data.auth_url);
|
||||
}
|
||||
|
||||
// 同时更新 ref 和 state,确保轮询能立即读取到最新值
|
||||
sessionIdRef.current = response.data.session_id;
|
||||
setWechatAuthUrl(response.data.auth_url);
|
||||
@@ -297,6 +347,11 @@ export default function WechatRegister() {
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
// 追踪获取二维码失败
|
||||
authEvents.trackError('api', error.message || '获取二维码失败', {
|
||||
context: 'get_wechat_qrcode'
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'getWechatQRCode', error);
|
||||
if (isMountedRef.current) {
|
||||
showError("获取微信授权失败", error.message || "请稍后重试");
|
||||
@@ -306,7 +361,7 @@ export default function WechatRegister() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [startPolling, showError]);
|
||||
}, [startPolling, showError, wechatSessionId, authEvents]);
|
||||
|
||||
/**
|
||||
* 安全的按钮点击处理,确保所有错误都被捕获,防止被 ErrorBoundary 捕获
|
||||
|
||||
83
src/components/PostHogProvider.js
Normal file
83
src/components/PostHogProvider.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/PostHogProvider.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { initPostHog } from '../lib/posthog';
|
||||
import { usePageTracking } from '../hooks/usePageTracking';
|
||||
|
||||
/**
|
||||
* PostHog Provider Component
|
||||
* Initializes PostHog SDK and provides automatic page view tracking
|
||||
*
|
||||
* Usage:
|
||||
* <PostHogProvider>
|
||||
* <App />
|
||||
* </PostHogProvider>
|
||||
*/
|
||||
export const PostHogProvider = ({ children }) => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Initialize PostHog once when component mounts
|
||||
useEffect(() => {
|
||||
// Only run in browser
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Initialize PostHog
|
||||
initPostHog();
|
||||
setIsInitialized(true);
|
||||
|
||||
// Log initialization
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHogProvider initialized');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Automatically track page views
|
||||
usePageTracking({
|
||||
enabled: isInitialized,
|
||||
getProperties: (location) => {
|
||||
// Add custom properties based on route
|
||||
const properties = {};
|
||||
|
||||
// Identify page type based on path
|
||||
if (location.pathname === '/home' || location.pathname === '/home/') {
|
||||
properties.page_type = 'landing';
|
||||
} else if (location.pathname.startsWith('/home/center')) {
|
||||
properties.page_type = 'dashboard';
|
||||
} else if (location.pathname.startsWith('/auth/')) {
|
||||
properties.page_type = 'auth';
|
||||
} else if (location.pathname.startsWith('/community')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'community';
|
||||
} else if (location.pathname.startsWith('/concepts')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'concepts';
|
||||
} else if (location.pathname.startsWith('/stocks')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'stocks';
|
||||
} else if (location.pathname.startsWith('/limit-analyse')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'limit_analyse';
|
||||
} else if (location.pathname.startsWith('/trading-simulation')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'trading_simulation';
|
||||
} else if (location.pathname.startsWith('/company')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'company';
|
||||
} else if (location.pathname.startsWith('/event-detail')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'event';
|
||||
}
|
||||
|
||||
return properties;
|
||||
},
|
||||
});
|
||||
|
||||
// Don't render children until PostHog is initialized
|
||||
// This prevents tracking events before SDK is ready
|
||||
if (!isInitialized) {
|
||||
return children; // Or return a loading spinner
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default PostHogProvider;
|
||||
463
src/hooks/useAuthEvents.js
Normal file
463
src/hooks/useAuthEvents.js
Normal file
@@ -0,0 +1,463 @@
|
||||
// src/hooks/useAuthEvents.js
|
||||
// 认证事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack, usePostHogUser } from './usePostHogRedux';
|
||||
import { ACTIVATION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 认证事件追踪 Hook
|
||||
* 提供登录/注册流程中所有关键节点的事件追踪功能
|
||||
*
|
||||
* 用法示例:
|
||||
*
|
||||
* ```jsx
|
||||
* import { useAuthEvents } from 'hooks/useAuthEvents';
|
||||
*
|
||||
* function AuthComponent() {
|
||||
* const {
|
||||
* trackLoginPageViewed,
|
||||
* trackPhoneLoginInitiated,
|
||||
* trackVerificationCodeSent,
|
||||
* trackLoginSuccess
|
||||
* } = useAuthEvents();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* trackLoginPageViewed();
|
||||
* }, [trackLoginPageViewed]);
|
||||
*
|
||||
* const handlePhoneFocus = () => {
|
||||
* trackPhoneLoginInitiated(formData.phone);
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.component - 组件名称 ('AuthFormContent' | 'WechatRegister')
|
||||
* @param {boolean} options.isMobile - 是否为移动设备
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
const { identify } = usePostHogUser();
|
||||
|
||||
// 通用事件属性
|
||||
const getBaseProperties = useCallback(() => ({
|
||||
component,
|
||||
device: isMobile ? 'mobile' : 'desktop',
|
||||
timestamp: new Date().toISOString(),
|
||||
}), [component, isMobile]);
|
||||
|
||||
// ==================== 页面浏览事件 ====================
|
||||
|
||||
/**
|
||||
* 追踪登录页面浏览
|
||||
*/
|
||||
const trackLoginPageViewed = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 Login Page Viewed', { component });
|
||||
}, [track, getBaseProperties, component]);
|
||||
|
||||
// ==================== 登录方式选择 ====================
|
||||
|
||||
/**
|
||||
* 追踪用户开始手机号登录
|
||||
* @param {string} phone - 手机号(可选,用于判断是否已填写)
|
||||
*/
|
||||
const trackPhoneLoginInitiated = useCallback((phone = '') => {
|
||||
track(ACTIVATION_EVENTS.PHONE_LOGIN_INITIATED, {
|
||||
...getBaseProperties(),
|
||||
has_phone: Boolean(phone),
|
||||
});
|
||||
logger.debug('useAuthEvents', '📱 Phone Login Initiated', { hasPhone: Boolean(phone) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户选择微信登录
|
||||
* @param {string} source - 触发来源 ('qr_area' | 'icon_button' | 'h5_redirect')
|
||||
*/
|
||||
const trackWechatLoginInitiated = useCallback((source = 'qr_area') => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_LOGIN_INITIATED, {
|
||||
...getBaseProperties(),
|
||||
source,
|
||||
});
|
||||
logger.debug('useAuthEvents', '💬 WeChat Login Initiated', { source });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 手机验证码流程 ====================
|
||||
|
||||
/**
|
||||
* 追踪验证码发送成功
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} purpose - 发送目的 ('login' | 'register')
|
||||
*/
|
||||
const trackVerificationCodeSent = useCallback((phone, purpose = 'login') => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SENT, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
purpose,
|
||||
});
|
||||
logger.debug('useAuthEvents', '✉️ Verification Code Sent', { phone: phone?.substring(0, 3) + '****', purpose });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪验证码发送失败
|
||||
* @param {string} phone - 手机号
|
||||
* @param {Error|string} error - 错误对象或错误消息
|
||||
*/
|
||||
const trackVerificationCodeSendFailed = useCallback((phone, error) => {
|
||||
const errorMessage = typeof error === 'string' ? error : error?.message || 'Unknown error';
|
||||
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SEND_FAILED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
error_message: errorMessage,
|
||||
error_type: 'send_code_failed',
|
||||
});
|
||||
logger.debug('useAuthEvents', '❌ Verification Code Send Failed', { error: errorMessage });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户输入验证码
|
||||
* @param {number} codeLength - 当前输入的验证码长度
|
||||
*/
|
||||
const trackVerificationCodeInputChanged = useCallback((codeLength) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_INPUT_CHANGED, {
|
||||
...getBaseProperties(),
|
||||
code_length: codeLength,
|
||||
is_complete: codeLength >= 6,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⌨️ Verification Code Input Changed', { codeLength });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪重新发送验证码
|
||||
* @param {string} phone - 手机号
|
||||
* @param {number} attemptCount - 第几次重发(可选)
|
||||
*/
|
||||
const trackVerificationCodeResent = useCallback((phone, attemptCount = 1) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_RESENT, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
attempt_count: attemptCount,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 Verification Code Resent', { attempt: attemptCount });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪手机号验证结果
|
||||
* @param {string} phone - 手机号
|
||||
* @param {boolean} isValid - 是否有效
|
||||
* @param {string} errorType - 错误类型(可选)
|
||||
*/
|
||||
const trackPhoneNumberValidated = useCallback((phone, isValid, errorType = '') => {
|
||||
track(ACTIVATION_EVENTS.PHONE_NUMBER_VALIDATED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
is_valid: isValid,
|
||||
error_type: errorType,
|
||||
});
|
||||
logger.debug('useAuthEvents', '✓ Phone Number Validated', { isValid, errorType });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪验证码提交
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
const trackVerificationCodeSubmitted = useCallback((phone) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SUBMITTED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
});
|
||||
logger.debug('useAuthEvents', '📤 Verification Code Submitted');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 微信登录流程 ====================
|
||||
|
||||
/**
|
||||
* 追踪微信二维码显示
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {string} authUrl - 授权URL
|
||||
*/
|
||||
const trackWechatQRDisplayed = useCallback((sessionId, authUrl = '') => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_DISPLAYED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
has_auth_url: Boolean(authUrl),
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔲 WeChat QR Code Displayed', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信二维码被扫描
|
||||
* @param {string} sessionId - 会话ID
|
||||
*/
|
||||
const trackWechatQRScanned = useCallback((sessionId) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_SCANNED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '📱 WeChat QR Code Scanned', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信二维码过期
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {number} timeElapsed - 经过时间(秒)
|
||||
*/
|
||||
const trackWechatQRExpired = useCallback((sessionId, timeElapsed = 0) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_EXPIRED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
time_elapsed: timeElapsed,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⏰ WeChat QR Code Expired', { sessionId: sessionId?.substring(0, 8), timeElapsed });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪刷新微信二维码
|
||||
* @param {string} oldSessionId - 旧会话ID
|
||||
* @param {string} newSessionId - 新会话ID
|
||||
*/
|
||||
const trackWechatQRRefreshed = useCallback((oldSessionId, newSessionId) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_REFRESHED, {
|
||||
...getBaseProperties(),
|
||||
old_session_id: oldSessionId?.substring(0, 8) + '...',
|
||||
new_session_id: newSessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat QR Code Refreshed');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信登录状态变化
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {string} oldStatus - 旧状态
|
||||
* @param {string} newStatus - 新状态
|
||||
*/
|
||||
const trackWechatStatusChanged = useCallback((sessionId, oldStatus, newStatus) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_STATUS_CHANGED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
old_status: oldStatus,
|
||||
new_status: newStatus,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat Status Changed', { oldStatus, newStatus });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪移动端跳转微信H5授权
|
||||
*/
|
||||
const trackWechatH5Redirect = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_H5_REDIRECT, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '🔗 WeChat H5 Redirect');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 登录/注册结果 ====================
|
||||
|
||||
/**
|
||||
* 追踪登录成功并识别用户
|
||||
* @param {Object} user - 用户对象
|
||||
* @param {string} loginMethod - 登录方式 ('wechat' | 'phone')
|
||||
* @param {boolean} isNewUser - 是否为新注册用户
|
||||
*/
|
||||
const trackLoginSuccess = useCallback((user, loginMethod, isNewUser = false) => {
|
||||
// 追踪登录成功事件
|
||||
const eventName = isNewUser ? ACTIVATION_EVENTS.USER_SIGNED_UP : ACTIVATION_EVENTS.USER_LOGGED_IN;
|
||||
|
||||
track(eventName, {
|
||||
...getBaseProperties(),
|
||||
user_id: user.id,
|
||||
login_method: loginMethod,
|
||||
is_new_user: isNewUser,
|
||||
has_nickname: Boolean(user.nickname),
|
||||
has_email: Boolean(user.email),
|
||||
has_wechat: Boolean(user.wechat_open_id),
|
||||
});
|
||||
|
||||
// 识别用户(关联 PostHog 用户)
|
||||
identify(user.id.toString(), {
|
||||
phone: user.phone,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
login_method: loginMethod,
|
||||
is_new_user: isNewUser,
|
||||
registration_date: user.created_at,
|
||||
last_login: new Date().toISOString(),
|
||||
has_wechat: Boolean(user.wechat_open_id),
|
||||
wechat_open_id: user.wechat_open_id,
|
||||
wechat_union_id: user.wechat_union_id,
|
||||
});
|
||||
|
||||
logger.debug('useAuthEvents', `✅ ${isNewUser ? 'User Signed Up' : 'User Logged In'}`, {
|
||||
userId: user.id,
|
||||
method: loginMethod,
|
||||
isNewUser,
|
||||
});
|
||||
}, [track, identify, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪登录失败
|
||||
* @param {string} loginMethod - 登录方式 ('wechat' | 'phone')
|
||||
* @param {string} errorType - 错误类型
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @param {Object} context - 额外上下文信息
|
||||
*/
|
||||
const trackLoginFailed = useCallback((loginMethod, errorType, errorMessage, context = {}) => {
|
||||
track(ACTIVATION_EVENTS.LOGIN_FAILED, {
|
||||
...getBaseProperties(),
|
||||
login_method: loginMethod,
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
...context,
|
||||
});
|
||||
logger.debug('useAuthEvents', '❌ Login Failed', { method: loginMethod, errorType, errorMessage });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 用户行为细节 ====================
|
||||
|
||||
/**
|
||||
* 追踪表单字段聚焦
|
||||
* @param {string} fieldName - 字段名称 ('phone' | 'verificationCode')
|
||||
*/
|
||||
const trackFormFocused = useCallback((fieldName) => {
|
||||
track(ACTIVATION_EVENTS.AUTH_FORM_FOCUSED, {
|
||||
...getBaseProperties(),
|
||||
field_name: fieldName,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🎯 Form Field Focused', { fieldName });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪表单验证错误
|
||||
* @param {string} fieldName - 字段名称
|
||||
* @param {string} errorType - 错误类型
|
||||
* @param {string} errorMessage - 错误消息
|
||||
*/
|
||||
const trackFormValidationError = useCallback((fieldName, errorType, errorMessage) => {
|
||||
track(ACTIVATION_EVENTS.AUTH_FORM_VALIDATION_ERROR, {
|
||||
...getBaseProperties(),
|
||||
field_name: fieldName,
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⚠️ Form Validation Error', { fieldName, errorType });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪昵称设置引导弹窗显示
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
const trackNicknamePromptShown = useCallback((phone) => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_SHOWN, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
});
|
||||
logger.debug('useAuthEvents', '💬 Nickname Prompt Shown');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户接受设置昵称
|
||||
*/
|
||||
const trackNicknamePromptAccepted = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_ACCEPTED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '✅ Nickname Prompt Accepted');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户跳过设置昵称
|
||||
*/
|
||||
const trackNicknamePromptSkipped = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_SKIPPED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '⏭️ Nickname Prompt Skipped');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户点击用户协议链接
|
||||
*/
|
||||
const trackUserAgreementClicked = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.USER_AGREEMENT_LINK_CLICKED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 User Agreement Link Clicked');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户点击隐私政策链接
|
||||
*/
|
||||
const trackPrivacyPolicyClicked = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.PRIVACY_POLICY_LINK_CLICKED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 Privacy Policy Link Clicked');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 错误追踪 ====================
|
||||
|
||||
/**
|
||||
* 追踪通用错误
|
||||
* @param {string} errorType - 错误类型 ('network' | 'api' | 'validation' | 'session')
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @param {Object} context - 错误上下文
|
||||
*/
|
||||
const trackError = useCallback((errorType, errorMessage, context = {}) => {
|
||||
const eventMap = {
|
||||
network: ACTIVATION_EVENTS.NETWORK_ERROR_OCCURRED,
|
||||
api: ACTIVATION_EVENTS.API_ERROR_OCCURRED,
|
||||
session: ACTIVATION_EVENTS.SESSION_EXPIRED,
|
||||
default: ACTIVATION_EVENTS.LOGIN_ERROR_OCCURRED,
|
||||
};
|
||||
|
||||
const eventName = eventMap[errorType] || eventMap.default;
|
||||
|
||||
track(eventName, {
|
||||
...getBaseProperties(),
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
...context,
|
||||
});
|
||||
logger.error('useAuthEvents', `❌ ${errorType} Error`, { errorMessage, context });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 返回接口 ====================
|
||||
|
||||
return {
|
||||
// 页面浏览
|
||||
trackLoginPageViewed,
|
||||
|
||||
// 登录方式选择
|
||||
trackPhoneLoginInitiated,
|
||||
trackWechatLoginInitiated,
|
||||
|
||||
// 手机验证码流程
|
||||
trackVerificationCodeSent,
|
||||
trackVerificationCodeSendFailed,
|
||||
trackVerificationCodeInputChanged,
|
||||
trackVerificationCodeResent,
|
||||
trackPhoneNumberValidated,
|
||||
trackVerificationCodeSubmitted,
|
||||
|
||||
// 微信登录流程
|
||||
trackWechatQRDisplayed,
|
||||
trackWechatQRScanned,
|
||||
trackWechatQRExpired,
|
||||
trackWechatQRRefreshed,
|
||||
trackWechatStatusChanged,
|
||||
trackWechatH5Redirect,
|
||||
|
||||
// 登录/注册结果
|
||||
trackLoginSuccess,
|
||||
trackLoginFailed,
|
||||
|
||||
// 用户行为
|
||||
trackFormFocused,
|
||||
trackFormValidationError,
|
||||
trackNicknamePromptShown,
|
||||
trackNicknamePromptAccepted,
|
||||
trackNicknamePromptSkipped,
|
||||
trackUserAgreementClicked,
|
||||
trackPrivacyPolicyClicked,
|
||||
|
||||
// 错误追踪
|
||||
trackError,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAuthEvents;
|
||||
55
src/hooks/usePageTracking.js
Normal file
55
src/hooks/usePageTracking.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/hooks/usePageTracking.js
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* Custom hook for automatic page view tracking with PostHog
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.enabled - Whether tracking is enabled
|
||||
* @param {Function} options.getProperties - Function to get custom properties for each page view
|
||||
*/
|
||||
export const usePageTracking = ({ enabled = true, getProperties } = {}) => {
|
||||
const location = useLocation();
|
||||
const previousPathRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Get the current path
|
||||
const currentPath = location.pathname + location.search;
|
||||
|
||||
// Skip if it's the same page (prevents duplicate tracking)
|
||||
if (previousPathRef.current === currentPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the previous path
|
||||
previousPathRef.current = currentPath;
|
||||
|
||||
// Get custom properties if function provided
|
||||
const customProperties = getProperties ? getProperties(location) : {};
|
||||
|
||||
// Track page view with PostHog
|
||||
if (posthog && posthog.__loaded) {
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: window.location.href,
|
||||
path: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
...customProperties,
|
||||
});
|
||||
|
||||
// Log in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📊 PostHog $pageview:', {
|
||||
path: location.pathname,
|
||||
...customProperties,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [location, enabled, getProperties]);
|
||||
};
|
||||
|
||||
export default usePageTracking;
|
||||
101
src/hooks/usePostHog.js
Normal file
101
src/hooks/usePostHog.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/hooks/usePostHog.js
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
getPostHog,
|
||||
trackEvent,
|
||||
trackPageView,
|
||||
identifyUser,
|
||||
setUserProperties,
|
||||
resetUser,
|
||||
optIn,
|
||||
optOut,
|
||||
hasOptedOut,
|
||||
getFeatureFlag,
|
||||
isFeatureEnabled,
|
||||
} from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* Custom hook to access PostHog functionality
|
||||
* Provides convenient methods for tracking events and managing user sessions
|
||||
*
|
||||
* @returns {object} PostHog methods
|
||||
*/
|
||||
export const usePostHog = () => {
|
||||
// Get PostHog instance
|
||||
const posthog = getPostHog();
|
||||
|
||||
// Track custom event
|
||||
const track = useCallback((eventName, properties = {}) => {
|
||||
trackEvent(eventName, properties);
|
||||
}, []);
|
||||
|
||||
// Track page view
|
||||
const trackPage = useCallback((pagePath, properties = {}) => {
|
||||
trackPageView(pagePath, properties);
|
||||
}, []);
|
||||
|
||||
// Identify user
|
||||
const identify = useCallback((userId, userProperties = {}) => {
|
||||
identifyUser(userId, userProperties);
|
||||
}, []);
|
||||
|
||||
// Set user properties
|
||||
const setProperties = useCallback((properties) => {
|
||||
setUserProperties(properties);
|
||||
}, []);
|
||||
|
||||
// Reset user session (logout)
|
||||
const reset = useCallback(() => {
|
||||
resetUser();
|
||||
}, []);
|
||||
|
||||
// Opt out of tracking
|
||||
const optOutTracking = useCallback(() => {
|
||||
optOut();
|
||||
}, []);
|
||||
|
||||
// Opt in to tracking
|
||||
const optInTracking = useCallback(() => {
|
||||
optIn();
|
||||
}, []);
|
||||
|
||||
// Check if user has opted out
|
||||
const isOptedOut = useCallback(() => {
|
||||
return hasOptedOut();
|
||||
}, []);
|
||||
|
||||
// Get feature flag value
|
||||
const getFlag = useCallback((flagKey, defaultValue = false) => {
|
||||
return getFeatureFlag(flagKey, defaultValue);
|
||||
}, []);
|
||||
|
||||
// Check if feature is enabled
|
||||
const isEnabled = useCallback((flagKey) => {
|
||||
return isFeatureEnabled(flagKey);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Core PostHog instance
|
||||
posthog,
|
||||
|
||||
// Tracking methods
|
||||
track,
|
||||
trackPage,
|
||||
|
||||
// User management
|
||||
identify,
|
||||
setProperties,
|
||||
reset,
|
||||
|
||||
// Privacy controls
|
||||
optOut: optOutTracking,
|
||||
optIn: optInTracking,
|
||||
isOptedOut,
|
||||
|
||||
// Feature flags
|
||||
getFlag,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePostHog;
|
||||
272
src/hooks/usePostHogRedux.js
Normal file
272
src/hooks/usePostHogRedux.js
Normal file
@@ -0,0 +1,272 @@
|
||||
// src/hooks/usePostHogRedux.js
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
trackEvent,
|
||||
identifyUser,
|
||||
resetUser,
|
||||
optIn,
|
||||
optOut,
|
||||
selectPostHog,
|
||||
selectIsInitialized,
|
||||
selectUser,
|
||||
selectFeatureFlags,
|
||||
selectFeatureFlag,
|
||||
selectIsOptedOut,
|
||||
selectStats,
|
||||
flushCachedEvents,
|
||||
} from '../store/slices/posthogSlice';
|
||||
import { trackPageView } from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* PostHog Redux Hook
|
||||
* 提供便捷的 PostHog 功能访问接口
|
||||
*
|
||||
* 用法示例:
|
||||
*
|
||||
* ```jsx
|
||||
* import { usePostHogRedux } from 'hooks/usePostHogRedux';
|
||||
* import { RETENTION_EVENTS } from 'lib/constants';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const { track, identify, user, isInitialized } = usePostHogRedux();
|
||||
*
|
||||
* const handleClick = () => {
|
||||
* track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
* article_id: '123',
|
||||
* article_title: '标题',
|
||||
* });
|
||||
* };
|
||||
*
|
||||
* if (!isInitialized) {
|
||||
* return <div>正在加载...</div>;
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <button onClick={handleClick}>点击追踪</button>
|
||||
* {user && <p>当前用户: {user.userId}</p>}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const usePostHogRedux = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Selectors
|
||||
const posthog = useSelector(selectPostHog);
|
||||
const isInitialized = useSelector(selectIsInitialized);
|
||||
const user = useSelector(selectUser);
|
||||
const featureFlags = useSelector(selectFeatureFlags);
|
||||
const stats = useSelector(selectStats);
|
||||
|
||||
// ==================== 追踪事件 ====================
|
||||
|
||||
/**
|
||||
* 追踪自定义事件
|
||||
* @param {string} eventName - 事件名称(建议使用 constants.js 中的常量)
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
const track = useCallback(
|
||||
(eventName, properties = {}) => {
|
||||
dispatch(trackEvent({ eventName, properties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪页面浏览
|
||||
* @param {string} pagePath - 页面路径
|
||||
* @param {object} properties - 页面属性
|
||||
*/
|
||||
const trackPage = useCallback(
|
||||
(pagePath, properties = {}) => {
|
||||
trackPageView(pagePath, properties);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
/**
|
||||
* 识别用户(登录后调用)
|
||||
* @param {string} userId - 用户 ID
|
||||
* @param {object} userProperties - 用户属性
|
||||
*/
|
||||
const identify = useCallback(
|
||||
(userId, userProperties = {}) => {
|
||||
dispatch(identifyUser({ userId, userProperties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* 重置用户会话(登出时调用)
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
dispatch(resetUser());
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 隐私控制 ====================
|
||||
|
||||
/**
|
||||
* 用户选择退出追踪
|
||||
*/
|
||||
const optOutTracking = useCallback(() => {
|
||||
dispatch(optOut());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 用户选择加入追踪
|
||||
*/
|
||||
const optInTracking = useCallback(() => {
|
||||
dispatch(optIn());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 检查用户是否已退出追踪
|
||||
*/
|
||||
const isOptedOut = selectIsOptedOut();
|
||||
|
||||
// ==================== Feature Flags ====================
|
||||
|
||||
/**
|
||||
* 获取特定 Feature Flag 的值
|
||||
* @param {string} flagKey - Flag 键名
|
||||
* @returns {any} Flag 值
|
||||
*/
|
||||
const getFlag = useCallback(
|
||||
(flagKey) => {
|
||||
return selectFeatureFlag(flagKey)({ posthog });
|
||||
},
|
||||
[posthog]
|
||||
);
|
||||
|
||||
/**
|
||||
* 检查 Feature Flag 是否启用
|
||||
* @param {string} flagKey - Flag 键名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isEnabled = useCallback(
|
||||
(flagKey) => {
|
||||
const value = getFlag(flagKey);
|
||||
return Boolean(value);
|
||||
},
|
||||
[getFlag]
|
||||
);
|
||||
|
||||
// ==================== 离线事件管理 ====================
|
||||
|
||||
/**
|
||||
* 刷新缓存的离线事件
|
||||
*/
|
||||
const flushEvents = useCallback(() => {
|
||||
dispatch(flushCachedEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 返回接口 ====================
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isInitialized,
|
||||
user,
|
||||
featureFlags,
|
||||
stats,
|
||||
posthog, // 完整的 PostHog 状态
|
||||
|
||||
// 追踪方法
|
||||
track,
|
||||
trackPage,
|
||||
|
||||
// 用户管理
|
||||
identify,
|
||||
reset,
|
||||
|
||||
// 隐私控制
|
||||
optOut: optOutTracking,
|
||||
optIn: optInTracking,
|
||||
isOptedOut,
|
||||
|
||||
// Feature Flags
|
||||
getFlag,
|
||||
isEnabled,
|
||||
|
||||
// 离线事件
|
||||
flushEvents,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 便捷 Hooks ====================
|
||||
|
||||
/**
|
||||
* 仅获取追踪功能的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogTrack = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const track = useCallback(
|
||||
(eventName, properties = {}) => {
|
||||
dispatch(trackEvent({ eventName, properties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return { track };
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅获取 Feature Flags 的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogFlags = () => {
|
||||
const featureFlags = useSelector(selectFeatureFlags);
|
||||
const posthog = useSelector(selectPostHog);
|
||||
|
||||
const getFlag = useCallback(
|
||||
(flagKey) => {
|
||||
return selectFeatureFlag(flagKey)({ posthog });
|
||||
},
|
||||
[posthog]
|
||||
);
|
||||
|
||||
const isEnabled = useCallback(
|
||||
(flagKey) => {
|
||||
const value = getFlag(flagKey);
|
||||
return Boolean(value);
|
||||
},
|
||||
[getFlag]
|
||||
);
|
||||
|
||||
return {
|
||||
featureFlags,
|
||||
getFlag,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户信息的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogUser = () => {
|
||||
const user = useSelector(selectUser);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const identify = useCallback(
|
||||
(userId, userProperties = {}) => {
|
||||
dispatch(identifyUser({ userId, userProperties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch(resetUser());
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
user,
|
||||
identify,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePostHogRedux;
|
||||
381
src/lib/constants.js
Normal file
381
src/lib/constants.js
Normal file
@@ -0,0 +1,381 @@
|
||||
// src/lib/constants.js
|
||||
// PostHog Event Names and Constants
|
||||
// Organized by AARRR Framework (Acquisition, Activation, Retention, Referral, Revenue)
|
||||
|
||||
// ============================================================================
|
||||
// ACQUISITION (获客) - Landing page, marketing website events
|
||||
// ============================================================================
|
||||
export const ACQUISITION_EVENTS = {
|
||||
// Landing page
|
||||
LANDING_PAGE_VIEWED: 'Landing Page Viewed',
|
||||
CTA_BUTTON_CLICKED: 'CTA Button Clicked',
|
||||
FEATURE_CARD_VIEWED: 'Feature Card Viewed',
|
||||
FEATURE_VIDEO_PLAYED: 'Feature Video Played',
|
||||
|
||||
// Pricing page
|
||||
PRICING_PAGE_VIEWED: 'Pricing Page Viewed',
|
||||
PRICING_PLAN_VIEWED: 'Pricing Plan Viewed',
|
||||
PRICING_PLAN_SELECTED: 'Pricing Plan Selected',
|
||||
|
||||
// How to use page
|
||||
HOW_TO_USE_PAGE_VIEWED: 'How To Use Page Viewed',
|
||||
TUTORIAL_STEP_VIEWED: 'Tutorial Step Viewed',
|
||||
|
||||
// Roadmap page
|
||||
ROADMAP_PAGE_VIEWED: 'Roadmap Page Viewed',
|
||||
ROADMAP_ITEM_CLICKED: 'Roadmap Item Clicked',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ACTIVATION (激活) - Sign up, login, onboarding
|
||||
// ============================================================================
|
||||
export const ACTIVATION_EVENTS = {
|
||||
// Auth pages
|
||||
LOGIN_PAGE_VIEWED: 'Login Page Viewed',
|
||||
SIGNUP_PAGE_VIEWED: 'Signup Page Viewed',
|
||||
|
||||
// Login method selection
|
||||
PHONE_LOGIN_INITIATED: 'Phone Login Initiated', // 用户开始填写手机号
|
||||
WECHAT_LOGIN_INITIATED: 'WeChat Login Initiated', // 用户选择微信登录
|
||||
|
||||
// Phone verification code flow
|
||||
VERIFICATION_CODE_SENT: 'Verification Code Sent',
|
||||
VERIFICATION_CODE_SEND_FAILED: 'Verification Code Send Failed',
|
||||
VERIFICATION_CODE_INPUT_CHANGED: 'Verification Code Input Changed',
|
||||
VERIFICATION_CODE_RESENT: 'Verification Code Resent',
|
||||
VERIFICATION_CODE_SUBMITTED: 'Verification Code Submitted',
|
||||
PHONE_NUMBER_VALIDATED: 'Phone Number Validated',
|
||||
|
||||
// WeChat login flow
|
||||
WECHAT_QR_DISPLAYED: 'WeChat QR Code Displayed',
|
||||
WECHAT_QR_SCANNED: 'WeChat QR Code Scanned',
|
||||
WECHAT_QR_EXPIRED: 'WeChat QR Code Expired',
|
||||
WECHAT_QR_REFRESHED: 'WeChat QR Code Refreshed',
|
||||
WECHAT_STATUS_CHANGED: 'WeChat Status Changed',
|
||||
WECHAT_H5_REDIRECT: 'WeChat H5 Redirect', // 移动端跳转微信H5
|
||||
|
||||
// Login/Signup results
|
||||
USER_LOGGED_IN: 'User Logged In',
|
||||
USER_SIGNED_UP: 'User Signed Up',
|
||||
LOGIN_FAILED: 'Login Failed',
|
||||
SIGNUP_FAILED: 'Signup Failed',
|
||||
|
||||
// User behavior details
|
||||
AUTH_FORM_FOCUSED: 'Auth Form Field Focused',
|
||||
AUTH_FORM_VALIDATION_ERROR: 'Auth Form Validation Error',
|
||||
NICKNAME_PROMPT_SHOWN: 'Nickname Prompt Shown',
|
||||
NICKNAME_PROMPT_ACCEPTED: 'Nickname Prompt Accepted',
|
||||
NICKNAME_PROMPT_SKIPPED: 'Nickname Prompt Skipped',
|
||||
USER_AGREEMENT_LINK_CLICKED: 'User Agreement Link Clicked',
|
||||
PRIVACY_POLICY_LINK_CLICKED: 'Privacy Policy Link Clicked',
|
||||
|
||||
// Error tracking
|
||||
LOGIN_ERROR_OCCURRED: 'Login Error Occurred',
|
||||
NETWORK_ERROR_OCCURRED: 'Network Error Occurred',
|
||||
SESSION_EXPIRED: 'Session Expired',
|
||||
API_ERROR_OCCURRED: 'API Error Occurred',
|
||||
|
||||
// Onboarding
|
||||
ONBOARDING_STARTED: 'Onboarding Started',
|
||||
ONBOARDING_STEP_COMPLETED: 'Onboarding Step Completed',
|
||||
ONBOARDING_COMPLETED: 'Onboarding Completed',
|
||||
ONBOARDING_SKIPPED: 'Onboarding Skipped',
|
||||
|
||||
// User agreement (deprecated, use link clicked events instead)
|
||||
USER_AGREEMENT_VIEWED: 'User Agreement Viewed',
|
||||
USER_AGREEMENT_ACCEPTED: 'User Agreement Accepted',
|
||||
PRIVACY_POLICY_VIEWED: 'Privacy Policy Viewed',
|
||||
PRIVACY_POLICY_ACCEPTED: 'Privacy Policy Accepted',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RETENTION (留存) - Core product usage, feature engagement
|
||||
// ============================================================================
|
||||
export const RETENTION_EVENTS = {
|
||||
// Dashboard
|
||||
DASHBOARD_VIEWED: 'Dashboard Viewed',
|
||||
DASHBOARD_CENTER_VIEWED: 'Dashboard Center Viewed',
|
||||
FUNCTION_CARD_CLICKED: 'Function Card Clicked', // Core功能卡片点击
|
||||
|
||||
// Navigation
|
||||
TOP_NAV_CLICKED: 'Top Navigation Clicked',
|
||||
SIDEBAR_MENU_CLICKED: 'Sidebar Menu Clicked',
|
||||
MENU_ITEM_CLICKED: 'Menu Item Clicked',
|
||||
BREADCRUMB_CLICKED: 'Breadcrumb Clicked',
|
||||
|
||||
// Search
|
||||
SEARCH_INITIATED: 'Search Initiated',
|
||||
SEARCH_QUERY_SUBMITTED: 'Search Query Submitted',
|
||||
SEARCH_RESULT_CLICKED: 'Search Result Clicked',
|
||||
SEARCH_NO_RESULTS: 'Search No Results',
|
||||
SEARCH_FILTER_APPLIED: 'Search Filter Applied',
|
||||
|
||||
// News/Community (新闻催化分析)
|
||||
COMMUNITY_PAGE_VIEWED: 'Community Page Viewed',
|
||||
NEWS_LIST_VIEWED: 'News List Viewed',
|
||||
NEWS_ARTICLE_CLICKED: 'News Article Clicked',
|
||||
NEWS_DETAIL_OPENED: 'News Detail Opened',
|
||||
NEWS_TAB_CLICKED: 'News Tab Clicked', // 相关标的, 相关概念, etc.
|
||||
NEWS_FILTER_APPLIED: 'News Filter Applied',
|
||||
NEWS_SORTED: 'News Sorted',
|
||||
|
||||
// Concept Center (概念中心)
|
||||
CONCEPT_PAGE_VIEWED: 'Concept Page Viewed',
|
||||
CONCEPT_LIST_VIEWED: 'Concept List Viewed',
|
||||
CONCEPT_CLICKED: 'Concept Clicked',
|
||||
CONCEPT_DETAIL_VIEWED: 'Concept Detail Viewed',
|
||||
CONCEPT_STOCK_CLICKED: 'Concept Stock Clicked',
|
||||
|
||||
// Stock Center (个股中心)
|
||||
STOCK_OVERVIEW_VIEWED: 'Stock Overview Page Viewed',
|
||||
STOCK_LIST_VIEWED: 'Stock List Viewed',
|
||||
STOCK_SEARCHED: 'Stock Searched',
|
||||
STOCK_CLICKED: 'Stock Clicked',
|
||||
STOCK_DETAIL_VIEWED: 'Stock Detail Viewed',
|
||||
STOCK_TAB_CLICKED: 'Stock Tab Clicked', // 公司概览, 股票行情, 财务全景, 盈利预测
|
||||
|
||||
// Company Details
|
||||
COMPANY_OVERVIEW_VIEWED: 'Company Overview Viewed',
|
||||
COMPANY_FINANCIALS_VIEWED: 'Company Financials Viewed',
|
||||
COMPANY_FORECAST_VIEWED: 'Company Forecast Viewed',
|
||||
COMPANY_MARKET_DATA_VIEWED: 'Company Market Data Viewed',
|
||||
|
||||
// Limit Analysis (涨停分析)
|
||||
LIMIT_ANALYSE_PAGE_VIEWED: 'Limit Analyse Page Viewed',
|
||||
LIMIT_BOARD_CLICKED: 'Limit Board Clicked',
|
||||
LIMIT_SECTOR_EXPANDED: 'Limit Sector Expanded',
|
||||
LIMIT_SECTOR_ANALYSIS_VIEWED: 'Limit Sector Analysis Viewed',
|
||||
LIMIT_STOCK_CLICKED: 'Limit Stock Clicked',
|
||||
|
||||
// Trading Simulation (模拟盘交易)
|
||||
TRADING_SIMULATION_ENTERED: 'Trading Simulation Entered',
|
||||
SIMULATION_ORDER_PLACED: 'Simulation Order Placed',
|
||||
SIMULATION_HOLDINGS_VIEWED: 'Simulation Holdings Viewed',
|
||||
SIMULATION_HISTORY_VIEWED: 'Simulation History Viewed',
|
||||
SIMULATION_STOCK_SEARCHED: 'Simulation Stock Searched',
|
||||
|
||||
// Event Details
|
||||
EVENT_DETAIL_VIEWED: 'Event Detail Viewed',
|
||||
EVENT_ANALYSIS_VIEWED: 'Event Analysis Viewed',
|
||||
EVENT_TIMELINE_CLICKED: 'Event Timeline Clicked',
|
||||
|
||||
// Profile & Settings
|
||||
PROFILE_PAGE_VIEWED: 'Profile Page Viewed',
|
||||
PROFILE_UPDATED: 'Profile Updated',
|
||||
SETTINGS_PAGE_VIEWED: 'Settings Page Viewed',
|
||||
SETTINGS_CHANGED: 'Settings Changed',
|
||||
|
||||
// Subscription Management
|
||||
SUBSCRIPTION_PAGE_VIEWED: 'Subscription Page Viewed',
|
||||
UPGRADE_PLAN_CLICKED: 'Upgrade Plan Clicked',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// REFERRAL (推荐) - Sharing, inviting
|
||||
// ============================================================================
|
||||
export const REFERRAL_EVENTS = {
|
||||
// Sharing
|
||||
SHARE_BUTTON_CLICKED: 'Share Button Clicked',
|
||||
CONTENT_SHARED: 'Content Shared',
|
||||
SHARE_LINK_GENERATED: 'Share Link Generated',
|
||||
SHARE_MODAL_OPENED: 'Share Modal Opened',
|
||||
SHARE_MODAL_CLOSED: 'Share Modal Closed',
|
||||
|
||||
// Referral
|
||||
REFERRAL_PAGE_VIEWED: 'Referral Page Viewed',
|
||||
REFERRAL_LINK_COPIED: 'Referral Link Copied',
|
||||
REFERRAL_INVITE_SENT: 'Referral Invite Sent',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// REVENUE (收入) - Payment, subscription, monetization
|
||||
// ============================================================================
|
||||
export const REVENUE_EVENTS = {
|
||||
// Paywall
|
||||
PAYWALL_SHOWN: 'Paywall Shown',
|
||||
PAYWALL_DISMISSED: 'Paywall Dismissed',
|
||||
PAYWALL_UPGRADE_CLICKED: 'Paywall Upgrade Clicked',
|
||||
|
||||
// Payment
|
||||
PAYMENT_PAGE_VIEWED: 'Payment Page Viewed',
|
||||
PAYMENT_METHOD_SELECTED: 'Payment Method Selected',
|
||||
PAYMENT_INITIATED: 'Payment Initiated',
|
||||
PAYMENT_SUCCESSFUL: 'Payment Successful',
|
||||
PAYMENT_FAILED: 'Payment Failed',
|
||||
|
||||
// Subscription
|
||||
SUBSCRIPTION_CREATED: 'Subscription Created',
|
||||
SUBSCRIPTION_RENEWED: 'Subscription Renewed',
|
||||
SUBSCRIPTION_UPGRADED: 'Subscription Upgraded',
|
||||
SUBSCRIPTION_DOWNGRADED: 'Subscription Downgraded',
|
||||
SUBSCRIPTION_CANCELLED: 'Subscription Cancelled',
|
||||
SUBSCRIPTION_EXPIRED: 'Subscription Expired',
|
||||
|
||||
// Refund
|
||||
REFUND_REQUESTED: 'Refund Requested',
|
||||
REFUND_PROCESSED: 'Refund Processed',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SPECIAL EVENTS (特殊事件) - Errors, performance, chatbot
|
||||
// ============================================================================
|
||||
export const SPECIAL_EVENTS = {
|
||||
// Errors
|
||||
ERROR_OCCURRED: 'Error Occurred',
|
||||
API_ERROR: 'API Error',
|
||||
NOT_FOUND_404: '404 Not Found',
|
||||
|
||||
// Performance
|
||||
PAGE_LOAD_TIME: 'Page Load Time',
|
||||
API_RESPONSE_TIME: 'API Response Time',
|
||||
|
||||
// Chatbot (Dify)
|
||||
CHATBOT_OPENED: 'Chatbot Opened',
|
||||
CHATBOT_CLOSED: 'Chatbot Closed',
|
||||
CHATBOT_MESSAGE_SENT: 'Chatbot Message Sent',
|
||||
CHATBOT_MESSAGE_RECEIVED: 'Chatbot Message Received',
|
||||
CHATBOT_FEEDBACK_PROVIDED: 'Chatbot Feedback Provided',
|
||||
|
||||
// Scroll depth
|
||||
SCROLL_DEPTH_25: 'Scroll Depth 25%',
|
||||
SCROLL_DEPTH_50: 'Scroll Depth 50%',
|
||||
SCROLL_DEPTH_75: 'Scroll Depth 75%',
|
||||
SCROLL_DEPTH_100: 'Scroll Depth 100%',
|
||||
|
||||
// Session
|
||||
SESSION_STARTED: 'Session Started',
|
||||
SESSION_ENDED: 'Session Ended',
|
||||
USER_IDLE: 'User Idle',
|
||||
USER_RETURNED: 'User Returned',
|
||||
|
||||
// Logout
|
||||
USER_LOGGED_OUT: 'User Logged Out',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// USER PROPERTIES (用户属性)
|
||||
// ============================================================================
|
||||
export const USER_PROPERTIES = {
|
||||
// Identity
|
||||
EMAIL: 'email',
|
||||
USERNAME: 'username',
|
||||
USER_ID: 'user_id',
|
||||
PHONE: 'phone',
|
||||
|
||||
// Subscription
|
||||
SUBSCRIPTION_TIER: 'subscription_tier', // 'free', 'pro', 'enterprise'
|
||||
SUBSCRIPTION_STATUS: 'subscription_status', // 'active', 'expired', 'cancelled'
|
||||
SUBSCRIPTION_START_DATE: 'subscription_start_date',
|
||||
SUBSCRIPTION_END_DATE: 'subscription_end_date',
|
||||
|
||||
// Engagement
|
||||
REGISTRATION_DATE: 'registration_date',
|
||||
LAST_LOGIN: 'last_login',
|
||||
LOGIN_COUNT: 'login_count',
|
||||
DAYS_SINCE_REGISTRATION: 'days_since_registration',
|
||||
LIFETIME_VALUE: 'lifetime_value',
|
||||
|
||||
// Preferences
|
||||
PREFERRED_LANGUAGE: 'preferred_language',
|
||||
THEME_PREFERENCE: 'theme_preference', // 'light', 'dark'
|
||||
NOTIFICATION_ENABLED: 'notification_enabled',
|
||||
|
||||
// Attribution
|
||||
UTM_SOURCE: 'utm_source',
|
||||
UTM_MEDIUM: 'utm_medium',
|
||||
UTM_CAMPAIGN: 'utm_campaign',
|
||||
REFERRER: 'referrer',
|
||||
|
||||
// Behavioral
|
||||
FAVORITE_FEATURES: 'favorite_features',
|
||||
MOST_VISITED_PAGES: 'most_visited_pages',
|
||||
TOTAL_SESSIONS: 'total_sessions',
|
||||
AVERAGE_SESSION_DURATION: 'average_session_duration',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SUBSCRIPTION TIERS (订阅等级)
|
||||
// ============================================================================
|
||||
export const SUBSCRIPTION_TIERS = {
|
||||
FREE: 'free',
|
||||
PRO: 'pro',
|
||||
ENTERPRISE: 'enterprise',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PAGE TYPES (页面类型)
|
||||
// ============================================================================
|
||||
export const PAGE_TYPES = {
|
||||
LANDING: 'landing',
|
||||
DASHBOARD: 'dashboard',
|
||||
FEATURE: 'feature',
|
||||
DETAIL: 'detail',
|
||||
AUTH: 'auth',
|
||||
SETTINGS: 'settings',
|
||||
PAYMENT: 'payment',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// CONTENT TYPES (内容类型)
|
||||
// ============================================================================
|
||||
export const CONTENT_TYPES = {
|
||||
NEWS: 'news',
|
||||
STOCK: 'stock',
|
||||
CONCEPT: 'concept',
|
||||
ANALYSIS: 'analysis',
|
||||
EVENT: 'event',
|
||||
COMPANY: 'company',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SHARE CHANNELS (分享渠道)
|
||||
// ============================================================================
|
||||
export const SHARE_CHANNELS = {
|
||||
WECHAT: 'wechat',
|
||||
LINK: 'link',
|
||||
QRCODE: 'qrcode',
|
||||
EMAIL: 'email',
|
||||
COPY: 'copy',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// LOGIN METHODS (登录方式)
|
||||
// ============================================================================
|
||||
export const LOGIN_METHODS = {
|
||||
WECHAT: 'wechat',
|
||||
EMAIL: 'email',
|
||||
PHONE: 'phone',
|
||||
USERNAME: 'username',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PAYMENT METHODS (支付方式)
|
||||
// ============================================================================
|
||||
export const PAYMENT_METHODS = {
|
||||
WECHAT_PAY: 'wechat_pay',
|
||||
ALIPAY: 'alipay',
|
||||
CREDIT_CARD: 'credit_card',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper function to get all events
|
||||
// ============================================================================
|
||||
export const getAllEvents = () => {
|
||||
return {
|
||||
...ACQUISITION_EVENTS,
|
||||
...ACTIVATION_EVENTS,
|
||||
...RETENTION_EVENTS,
|
||||
...REFERRAL_EVENTS,
|
||||
...REVENUE_EVENTS,
|
||||
...SPECIAL_EVENTS,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper function to validate event name
|
||||
// ============================================================================
|
||||
export const isValidEvent = (eventName) => {
|
||||
const allEvents = getAllEvents();
|
||||
return Object.values(allEvents).includes(eventName);
|
||||
};
|
||||
271
src/lib/posthog.js
Normal file
271
src/lib/posthog.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// src/lib/posthog.js
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* Initialize PostHog SDK
|
||||
* Should be called once when the app starts
|
||||
*/
|
||||
export const initPostHog = () => {
|
||||
// Only run in browser environment
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const apiKey = process.env.REACT_APP_POSTHOG_KEY;
|
||||
const apiHost = process.env.REACT_APP_POSTHOG_HOST || 'https://app.posthog.com';
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('⚠️ PostHog API key not found. Analytics will be disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
|
||||
// Pageview tracking - manual control for better accuracy
|
||||
capture_pageview: false, // We'll manually capture with custom properties
|
||||
capture_pageleave: true, // Auto-capture when user leaves page
|
||||
|
||||
// Session Recording Configuration
|
||||
session_recording: {
|
||||
enabled: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||
|
||||
// Privacy: Mask sensitive input fields
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
'data-sensitive': true, // Custom attribute for sensitive fields
|
||||
},
|
||||
|
||||
// Record canvas for charts/graphs
|
||||
recordCanvas: true,
|
||||
|
||||
// Network payload capture (useful for debugging API issues)
|
||||
networkPayloadCapture: {
|
||||
recordHeaders: true,
|
||||
recordBody: true,
|
||||
// Don't record sensitive endpoints
|
||||
urlBlocklist: [
|
||||
'/api/auth/session',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/payment',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Performance optimization
|
||||
batch_size: 10, // Send events in batches of 10
|
||||
batch_interval_ms: 3000, // Or every 3 seconds
|
||||
|
||||
// Privacy settings
|
||||
respect_dnt: true, // Respect Do Not Track browser setting
|
||||
persistence: 'localStorage+cookie', // Use both for reliability
|
||||
|
||||
// Feature flags (for A/B testing)
|
||||
bootstrap: {
|
||||
featureFlags: {},
|
||||
},
|
||||
|
||||
// Autocapture settings
|
||||
autocapture: {
|
||||
// Automatically capture clicks on buttons, links, etc.
|
||||
dom_event_allowlist: ['click', 'submit', 'change'],
|
||||
|
||||
// Capture additional element properties
|
||||
capture_copied_text: false, // Don't capture copied text (privacy)
|
||||
},
|
||||
|
||||
// Development debugging
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
posthogInstance.debug(); // Enable debug mode in development
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('📊 PostHog Analytics initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ PostHog initialization failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get PostHog instance
|
||||
* @returns {object} PostHog instance
|
||||
*/
|
||||
export const getPostHog = () => {
|
||||
return posthog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Identify user with PostHog
|
||||
* Call this after successful login/registration
|
||||
*
|
||||
* @param {string} userId - Unique user identifier
|
||||
* @param {object} userProperties - User properties (email, name, subscription_tier, etc.)
|
||||
*/
|
||||
export const identifyUser = (userId, userProperties = {}) => {
|
||||
if (!userId) {
|
||||
console.warn('⚠️ Cannot identify user: userId is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
posthog.identify(userId, {
|
||||
email: userProperties.email,
|
||||
username: userProperties.username,
|
||||
subscription_tier: userProperties.subscription_tier || 'free',
|
||||
role: userProperties.role,
|
||||
registration_date: userProperties.registration_date,
|
||||
last_login: new Date().toISOString(),
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
console.log('👤 User identified:', userId);
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user properties
|
||||
* Use this to update user attributes without re-identifying
|
||||
*
|
||||
* @param {object} properties - Properties to update
|
||||
*/
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
console.log('📝 User properties updated');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track custom event
|
||||
*
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {object} properties - Event properties
|
||||
*/
|
||||
export const trackEvent = (eventName, properties = {}) => {
|
||||
try {
|
||||
posthog.capture(eventName, {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📍 Event tracked:', eventName, properties);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*
|
||||
* @param {string} pagePath - Current page path
|
||||
* @param {object} properties - Additional properties
|
||||
*/
|
||||
export const trackPageView = (pagePath, properties = {}) => {
|
||||
try {
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: window.location.href,
|
||||
page_path: pagePath,
|
||||
page_title: document.title,
|
||||
referrer: document.referrer,
|
||||
...properties,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📄 Page view tracked:', pagePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset user session
|
||||
* Call this on logout
|
||||
*/
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
console.log('🔄 User session reset');
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User opt-out from tracking
|
||||
*/
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
console.log('🚫 User opted out of tracking');
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User opt-in to tracking
|
||||
*/
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
console.log('✅ User opted in to tracking');
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has opted out
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const hasOptedOut = () => {
|
||||
try {
|
||||
return posthog.has_opted_out_capturing();
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check opt-out status:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get feature flag value
|
||||
* @param {string} flagKey - Feature flag key
|
||||
* @param {any} defaultValue - Default value if flag not found
|
||||
* @returns {any} Feature flag value
|
||||
*/
|
||||
export const getFeatureFlag = (flagKey, defaultValue = false) => {
|
||||
try {
|
||||
return posthog.getFeatureFlag(flagKey) || defaultValue;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get feature flag:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if feature flag is enabled
|
||||
* @param {string} flagKey - Feature flag key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isFeatureEnabled = (flagKey) => {
|
||||
try {
|
||||
return posthog.isFeatureEnabled(flagKey);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check feature flag:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default posthog;
|
||||
@@ -1,18 +1,25 @@
|
||||
// src/store/index.js
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import communityDataReducer from './slices/communityDataSlice';
|
||||
import posthogReducer from './slices/posthogSlice';
|
||||
import posthogMiddleware from './middleware/posthogMiddleware';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
communityData: communityDataReducer
|
||||
communityData: communityDataReducer,
|
||||
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
// 忽略这些 action types 的序列化检查
|
||||
ignoredActions: ['communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled'],
|
||||
ignoredActions: [
|
||||
'communityData/fetchPopularKeywords/fulfilled',
|
||||
'communityData/fetchHotEvents/fulfilled',
|
||||
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪
|
||||
],
|
||||
},
|
||||
}),
|
||||
}).concat(posthogMiddleware), // ✅ PostHog 自动追踪中间件
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
281
src/store/middleware/posthogMiddleware.js
Normal file
281
src/store/middleware/posthogMiddleware.js
Normal file
@@ -0,0 +1,281 @@
|
||||
// src/store/middleware/posthogMiddleware.js
|
||||
import { trackPageView } from '../../lib/posthog';
|
||||
import { trackEvent } from '../slices/posthogSlice';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
ACTIVATION_EVENTS,
|
||||
RETENTION_EVENTS,
|
||||
SPECIAL_EVENTS,
|
||||
REVENUE_EVENTS,
|
||||
} from '../../lib/constants';
|
||||
|
||||
// ==================== 自动追踪规则配置 ====================
|
||||
|
||||
/**
|
||||
* Action 到 PostHog 事件的映射
|
||||
* 当这些 Redux actions 被 dispatch 时,自动追踪对应的 PostHog 事件
|
||||
*/
|
||||
const ACTION_TO_EVENT_MAP = {
|
||||
// ==================== 登录/登出 ====================
|
||||
'auth/login/fulfilled': {
|
||||
event: ACTIVATION_EVENTS.USER_LOGGED_IN,
|
||||
getProperties: (action) => ({
|
||||
login_method: action.payload?.login_method || 'unknown',
|
||||
user_id: action.payload?.user?.id,
|
||||
}),
|
||||
},
|
||||
'auth/logout': {
|
||||
event: SPECIAL_EVENTS.USER_LOGGED_OUT,
|
||||
getProperties: () => ({}),
|
||||
},
|
||||
'auth/wechatLogin/fulfilled': {
|
||||
event: ACTIVATION_EVENTS.USER_LOGGED_IN,
|
||||
getProperties: (action) => ({
|
||||
login_method: 'wechat',
|
||||
user_id: action.payload?.user?.id,
|
||||
}),
|
||||
},
|
||||
|
||||
// ==================== Community/新闻模块 ====================
|
||||
'communityData/fetchHotEvents/fulfilled': {
|
||||
event: RETENTION_EVENTS.NEWS_LIST_VIEWED,
|
||||
getProperties: (action) => ({
|
||||
event_count: action.payload?.length || 0,
|
||||
source: 'community_page',
|
||||
}),
|
||||
},
|
||||
'communityData/fetchPopularKeywords/fulfilled': {
|
||||
event: RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED,
|
||||
getProperties: () => ({
|
||||
feature: 'popular_keywords',
|
||||
}),
|
||||
},
|
||||
|
||||
// ==================== 搜索 ====================
|
||||
'search/submit': {
|
||||
event: RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED,
|
||||
getProperties: (action) => ({
|
||||
query: action.payload?.query,
|
||||
category: action.payload?.category,
|
||||
}),
|
||||
},
|
||||
'search/filterApplied': {
|
||||
event: RETENTION_EVENTS.SEARCH_FILTER_APPLIED,
|
||||
getProperties: (action) => ({
|
||||
filter_type: action.payload?.filterType,
|
||||
filter_value: action.payload?.filterValue,
|
||||
}),
|
||||
},
|
||||
|
||||
// ==================== 支付/订阅 ====================
|
||||
'payment/initiated': {
|
||||
event: REVENUE_EVENTS.PAYMENT_INITIATED,
|
||||
getProperties: (action) => ({
|
||||
amount: action.payload?.amount,
|
||||
payment_method: action.payload?.method,
|
||||
subscription_tier: action.payload?.tier,
|
||||
}),
|
||||
},
|
||||
'payment/success': {
|
||||
event: REVENUE_EVENTS.PAYMENT_SUCCESSFUL,
|
||||
getProperties: (action) => ({
|
||||
amount: action.payload?.amount,
|
||||
transaction_id: action.payload?.transactionId,
|
||||
subscription_tier: action.payload?.tier,
|
||||
}),
|
||||
},
|
||||
'subscription/upgraded': {
|
||||
event: REVENUE_EVENTS.SUBSCRIPTION_UPGRADED,
|
||||
getProperties: (action) => ({
|
||||
from_tier: action.payload?.fromTier,
|
||||
to_tier: action.payload?.toTier,
|
||||
}),
|
||||
},
|
||||
|
||||
// ==================== 错误追踪 ====================
|
||||
'error/occurred': {
|
||||
event: SPECIAL_EVENTS.ERROR_OCCURRED,
|
||||
getProperties: (action) => ({
|
||||
error_type: action.payload?.errorType,
|
||||
error_message: action.payload?.message,
|
||||
stack_trace: action.payload?.stack,
|
||||
}),
|
||||
},
|
||||
'api/error': {
|
||||
event: SPECIAL_EVENTS.API_ERROR,
|
||||
getProperties: (action) => ({
|
||||
endpoint: action.payload?.endpoint,
|
||||
status_code: action.payload?.statusCode,
|
||||
error_message: action.payload?.message,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 页面路由追踪配置 ====================
|
||||
|
||||
/**
|
||||
* 路由变化的 action type(根据不同路由库调整)
|
||||
*/
|
||||
const LOCATION_CHANGE_ACTIONS = [
|
||||
'@@router/LOCATION_CHANGE', // Redux-first router
|
||||
'router/navigate', // 自定义路由 action
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据路径识别页面类型
|
||||
*/
|
||||
const getPageTypeFromPath = (pathname) => {
|
||||
if (pathname === '/home' || pathname === '/') {
|
||||
return { page_type: 'landing' };
|
||||
} else if (pathname.startsWith('/auth/')) {
|
||||
return { page_type: 'auth' };
|
||||
} else if (pathname.startsWith('/community')) {
|
||||
return { page_type: 'feature', feature_name: 'community' };
|
||||
} else if (pathname.startsWith('/concepts')) {
|
||||
return { page_type: 'feature', feature_name: 'concepts' };
|
||||
} else if (pathname.startsWith('/stocks')) {
|
||||
return { page_type: 'feature', feature_name: 'stocks' };
|
||||
} else if (pathname.startsWith('/limit-analyse')) {
|
||||
return { page_type: 'feature', feature_name: 'limit_analyse' };
|
||||
} else if (pathname.startsWith('/trading-simulation')) {
|
||||
return { page_type: 'feature', feature_name: 'trading_simulation' };
|
||||
} else if (pathname.startsWith('/company')) {
|
||||
return { page_type: 'detail', content_type: 'company' };
|
||||
} else if (pathname.startsWith('/event-detail')) {
|
||||
return { page_type: 'detail', content_type: 'event' };
|
||||
}
|
||||
return { page_type: 'other' };
|
||||
};
|
||||
|
||||
// ==================== 中间件实现 ====================
|
||||
|
||||
/**
|
||||
* PostHog Middleware
|
||||
* 自动拦截 Redux actions 并追踪对应的 PostHog 事件
|
||||
*/
|
||||
const posthogMiddleware = (store) => (next) => (action) => {
|
||||
// 先执行 action
|
||||
const result = next(action);
|
||||
|
||||
// 获取当前 PostHog 状态
|
||||
const state = store.getState();
|
||||
const posthogState = state.posthog;
|
||||
|
||||
// 如果 PostHog 未初始化,不追踪(事件会被缓存到 eventQueue)
|
||||
if (!posthogState?.isInitialized) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// ==================== 1. 自动追踪特定 actions ====================
|
||||
if (ACTION_TO_EVENT_MAP[action.type]) {
|
||||
const { event, getProperties } = ACTION_TO_EVENT_MAP[action.type];
|
||||
const properties = getProperties(action);
|
||||
|
||||
// 通过 dispatch 追踪事件(会走 Redux 状态管理)
|
||||
store.dispatch(trackEvent({ eventName: event, properties }));
|
||||
|
||||
logger.debug('PostHog Middleware', `自动追踪事件: ${event}`, properties);
|
||||
}
|
||||
|
||||
// ==================== 2. 路由变化追踪 ====================
|
||||
if (LOCATION_CHANGE_ACTIONS.includes(action.type)) {
|
||||
const location = action.payload?.location || action.payload;
|
||||
const pathname = location?.pathname || window.location.pathname;
|
||||
const search = location?.search || window.location.search;
|
||||
|
||||
// 识别页面类型
|
||||
const pageProperties = getPageTypeFromPath(pathname);
|
||||
|
||||
// 追踪页面浏览
|
||||
trackPageView(pathname, {
|
||||
...pageProperties,
|
||||
page_path: pathname,
|
||||
page_search: search,
|
||||
page_title: document.title,
|
||||
referrer: document.referrer,
|
||||
});
|
||||
|
||||
logger.debug('PostHog Middleware', `页面浏览追踪: ${pathname}`, pageProperties);
|
||||
}
|
||||
|
||||
// ==================== 3. 离线事件处理 ====================
|
||||
// 检测网络状态变化
|
||||
if (action.type === 'network/online') {
|
||||
// 恢复在线时,刷新缓存的事件
|
||||
const { eventQueue } = posthogState;
|
||||
if (eventQueue && eventQueue.length > 0) {
|
||||
logger.info('PostHog Middleware', `网络恢复,刷新 ${eventQueue.length} 个缓存事件`);
|
||||
// 这里可以 dispatch flushCachedEvents,但为了避免循环依赖,直接在 slice 中处理
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 4. 性能追踪(可选) ====================
|
||||
// 追踪耗时较长的 actions
|
||||
const startTime = action.meta?.startTime;
|
||||
if (startTime) {
|
||||
const duration = Date.now() - startTime;
|
||||
if (duration > 1000) {
|
||||
// 超过 1 秒的操作
|
||||
store.dispatch(trackEvent({
|
||||
eventName: SPECIAL_EVENTS.PAGE_LOAD_TIME,
|
||||
properties: {
|
||||
action_type: action.type,
|
||||
duration_ms: duration,
|
||||
is_slow: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PostHog Middleware', '追踪失败', error, { actionType: action.type });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 创建带性能追踪的 action creator
|
||||
* 用法: dispatch(withTiming(someAction(payload)))
|
||||
*/
|
||||
export const withTiming = (action) => ({
|
||||
...action,
|
||||
meta: {
|
||||
...action.meta,
|
||||
startTime: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 手动触发页面浏览追踪
|
||||
* 用于非路由跳转的场景(如 Modal、Tab 切换)
|
||||
*/
|
||||
export const trackModalView = (modalName, properties = {}) => (dispatch) => {
|
||||
dispatch(trackEvent({
|
||||
eventName: '$pageview',
|
||||
properties: {
|
||||
modal_name: modalName,
|
||||
page_type: 'modal',
|
||||
...properties,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 追踪 Tab 切换
|
||||
*/
|
||||
export const trackTabChange = (tabName, properties = {}) => (dispatch) => {
|
||||
dispatch(trackEvent({
|
||||
eventName: RETENTION_EVENTS.NEWS_TAB_CLICKED,
|
||||
properties: {
|
||||
tab_name: tabName,
|
||||
...properties,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// ==================== Export ====================
|
||||
|
||||
export default posthogMiddleware;
|
||||
299
src/store/slices/posthogSlice.js
Normal file
299
src/store/slices/posthogSlice.js
Normal file
@@ -0,0 +1,299 @@
|
||||
// src/store/slices/posthogSlice.js
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import {
|
||||
initPostHog,
|
||||
identifyUser as posthogIdentifyUser,
|
||||
resetUser as posthogResetUser,
|
||||
trackEvent as posthogTrackEvent,
|
||||
getFeatureFlag as posthogGetFeatureFlag,
|
||||
optIn as posthogOptIn,
|
||||
optOut as posthogOptOut,
|
||||
hasOptedOut as posthogHasOptedOut
|
||||
} from '../../lib/posthog';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ==================== Initial State ====================
|
||||
|
||||
const initialState = {
|
||||
// 初始化状态
|
||||
isInitialized: false,
|
||||
initError: null,
|
||||
|
||||
// 用户信息
|
||||
user: null,
|
||||
|
||||
// 事件队列(用于离线缓存)
|
||||
eventQueue: [],
|
||||
|
||||
// Feature Flags
|
||||
featureFlags: {},
|
||||
|
||||
// 配置
|
||||
config: {
|
||||
apiKey: process.env.REACT_APP_POSTHOG_KEY || null,
|
||||
apiHost: process.env.REACT_APP_POSTHOG_HOST || 'https://app.posthog.com',
|
||||
sessionRecording: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||
},
|
||||
|
||||
// 统计
|
||||
stats: {
|
||||
totalEvents: 0,
|
||||
lastEventTime: null,
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
* 初始化 PostHog SDK
|
||||
*/
|
||||
export const initializePostHog = createAsyncThunk(
|
||||
'posthog/initialize',
|
||||
async (_, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { config } = getState().posthog;
|
||||
|
||||
if (!config.apiKey) {
|
||||
logger.warn('PostHog', '未配置 API Key,分析功能将被禁用');
|
||||
return { isInitialized: false, warning: 'No API Key' };
|
||||
}
|
||||
|
||||
// 调用 PostHog SDK 初始化
|
||||
initPostHog();
|
||||
|
||||
logger.info('PostHog', 'Redux 初始化成功');
|
||||
|
||||
return { isInitialized: true };
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '初始化失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 识别用户
|
||||
*/
|
||||
export const identifyUser = createAsyncThunk(
|
||||
'posthog/identifyUser',
|
||||
async ({ userId, userProperties }, { rejectWithValue }) => {
|
||||
try {
|
||||
posthogIdentifyUser(userId, userProperties);
|
||||
logger.info('PostHog', '用户已识别', { userId });
|
||||
return { userId, userProperties };
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '用户识别失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 重置用户会话(登出)
|
||||
*/
|
||||
export const resetUser = createAsyncThunk(
|
||||
'posthog/resetUser',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
posthogResetUser();
|
||||
logger.info('PostHog', '用户会话已重置');
|
||||
return {};
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '重置用户会话失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪事件
|
||||
*/
|
||||
export const trackEvent = createAsyncThunk(
|
||||
'posthog/trackEvent',
|
||||
async ({ eventName, properties = {} }, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { isInitialized } = getState().posthog;
|
||||
|
||||
if (!isInitialized) {
|
||||
logger.warn('PostHog', 'PostHog 未初始化,事件将被缓存', { eventName });
|
||||
return { eventName, properties, cached: true };
|
||||
}
|
||||
|
||||
posthogTrackEvent(eventName, properties);
|
||||
|
||||
return {
|
||||
eventName,
|
||||
properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
cached: false
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '追踪事件失败', error, { eventName });
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取所有 Feature Flags
|
||||
*/
|
||||
export const fetchFeatureFlags = createAsyncThunk(
|
||||
'posthog/fetchFeatureFlags',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
// PostHog SDK 会在初始化时自动获取 feature flags
|
||||
// 这里只是读取缓存的值
|
||||
const flags = {};
|
||||
logger.info('PostHog', 'Feature Flags 已更新');
|
||||
return flags;
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '获取 Feature Flags 失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 刷新缓存的离线事件
|
||||
*/
|
||||
export const flushCachedEvents = createAsyncThunk(
|
||||
'posthog/flushCachedEvents',
|
||||
async (_, { getState, dispatch }) => {
|
||||
try {
|
||||
const { eventQueue, isInitialized } = getState().posthog;
|
||||
|
||||
if (!isInitialized || eventQueue.length === 0) {
|
||||
return { flushed: 0 };
|
||||
}
|
||||
|
||||
logger.info('PostHog', `刷新 ${eventQueue.length} 个缓存事件`);
|
||||
|
||||
// 批量发送缓存的事件
|
||||
for (const { eventName, properties } of eventQueue) {
|
||||
dispatch(trackEvent({ eventName, properties }));
|
||||
}
|
||||
|
||||
return { flushed: eventQueue.length };
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '刷新缓存事件失败', error);
|
||||
return { flushed: 0, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Slice ====================
|
||||
|
||||
const posthogSlice = createSlice({
|
||||
name: 'posthog',
|
||||
initialState,
|
||||
reducers: {
|
||||
// 设置 Feature Flag
|
||||
setFeatureFlag: (state, action) => {
|
||||
const { flagKey, value } = action.payload;
|
||||
state.featureFlags[flagKey] = value;
|
||||
},
|
||||
|
||||
// 清空事件队列
|
||||
clearEventQueue: (state) => {
|
||||
state.eventQueue = [];
|
||||
},
|
||||
|
||||
// 更新配置
|
||||
updateConfig: (state, action) => {
|
||||
state.config = { ...state.config, ...action.payload };
|
||||
},
|
||||
|
||||
// 用户 Opt-in
|
||||
optIn: (state) => {
|
||||
posthogOptIn();
|
||||
logger.info('PostHog', '用户已选择加入追踪');
|
||||
},
|
||||
|
||||
// 用户 Opt-out
|
||||
optOut: (state) => {
|
||||
posthogOptOut();
|
||||
logger.info('PostHog', '用户已选择退出追踪');
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// 初始化
|
||||
builder.addCase(initializePostHog.fulfilled, (state, action) => {
|
||||
state.isInitialized = action.payload.isInitialized;
|
||||
state.initError = null;
|
||||
});
|
||||
builder.addCase(initializePostHog.rejected, (state, action) => {
|
||||
state.isInitialized = false;
|
||||
state.initError = action.payload;
|
||||
});
|
||||
|
||||
// 识别用户
|
||||
builder.addCase(identifyUser.fulfilled, (state, action) => {
|
||||
state.user = {
|
||||
userId: action.payload.userId,
|
||||
...action.payload.userProperties,
|
||||
};
|
||||
});
|
||||
|
||||
// 重置用户
|
||||
builder.addCase(resetUser.fulfilled, (state) => {
|
||||
state.user = null;
|
||||
state.featureFlags = {};
|
||||
});
|
||||
|
||||
// 追踪事件
|
||||
builder.addCase(trackEvent.fulfilled, (state, action) => {
|
||||
const { eventName, properties, timestamp, cached } = action.payload;
|
||||
|
||||
// 如果事件被缓存,添加到队列
|
||||
if (cached) {
|
||||
state.eventQueue.push({ eventName, properties, timestamp });
|
||||
} else {
|
||||
// 更新统计
|
||||
state.stats.totalEvents += 1;
|
||||
state.stats.lastEventTime = timestamp;
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新缓存事件
|
||||
builder.addCase(flushCachedEvents.fulfilled, (state, action) => {
|
||||
if (action.payload.flushed > 0) {
|
||||
state.eventQueue = [];
|
||||
state.stats.totalEvents += action.payload.flushed;
|
||||
}
|
||||
});
|
||||
|
||||
// 获取 Feature Flags
|
||||
builder.addCase(fetchFeatureFlags.fulfilled, (state, action) => {
|
||||
state.featureFlags = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
export const {
|
||||
setFeatureFlag,
|
||||
clearEventQueue,
|
||||
updateConfig,
|
||||
optIn,
|
||||
optOut,
|
||||
} = posthogSlice.actions;
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectPostHog = (state) => state.posthog;
|
||||
export const selectIsInitialized = (state) => state.posthog.isInitialized;
|
||||
export const selectUser = (state) => state.posthog.user;
|
||||
export const selectFeatureFlags = (state) => state.posthog.featureFlags;
|
||||
export const selectEventQueue = (state) => state.posthog.eventQueue;
|
||||
export const selectStats = (state) => state.posthog.stats;
|
||||
|
||||
export const selectFeatureFlag = (flagKey) => (state) => {
|
||||
return state.posthog.featureFlags[flagKey] || posthogGetFeatureFlag(flagKey);
|
||||
};
|
||||
|
||||
export const selectIsOptedOut = () => posthogHasOptedOut();
|
||||
|
||||
// ==================== Export ====================
|
||||
|
||||
export default posthogSlice.reducer;
|
||||
@@ -4,6 +4,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
|
||||
/**
|
||||
* 事件筛选逻辑 Hook
|
||||
@@ -15,6 +17,7 @@ import { logger } from '../../../utils/logger';
|
||||
*/
|
||||
export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪
|
||||
|
||||
// 筛选参数状态 - 初始化时从URL读取,之后只用本地状态
|
||||
const [filters, setFilters] = useState(() => {
|
||||
@@ -35,12 +38,68 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
oldFilters: filters,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 🎯 PostHog 追踪:搜索查询
|
||||
if (newFilters.q !== filters.q && newFilters.q) {
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query: newFilters.q,
|
||||
category: 'news',
|
||||
previous_query: filters.q || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:排序变化
|
||||
if (newFilters.sort !== filters.sort) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'sort',
|
||||
filter_value: newFilters.sort,
|
||||
previous_value: filters.sort,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:重要性筛选
|
||||
if (newFilters.importance !== filters.importance) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'importance',
|
||||
filter_value: newFilters.importance,
|
||||
previous_value: filters.importance,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:时间范围筛选
|
||||
if (newFilters.date_range !== filters.date_range && newFilters.date_range) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'date_range',
|
||||
filter_value: newFilters.date_range,
|
||||
previous_value: filters.date_range || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:行业筛选
|
||||
if (newFilters.industry_code !== filters.industry_code && newFilters.industry_code) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'industry',
|
||||
filter_value: newFilters.industry_code,
|
||||
previous_value: filters.industry_code || null,
|
||||
});
|
||||
}
|
||||
|
||||
setFilters(newFilters);
|
||||
logger.debug('useEventFilters', '✅ setFilters 已调用 (React异步更新中...)');
|
||||
}, [filters]);
|
||||
}, [filters, track]);
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = useCallback((page) => {
|
||||
// 🎯 PostHog 追踪:翻页
|
||||
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
page,
|
||||
filters: {
|
||||
sort: filters.sort,
|
||||
importance: filters.importance,
|
||||
has_query: !!filters.q,
|
||||
},
|
||||
});
|
||||
|
||||
// 保持现有筛选条件,只更新页码
|
||||
updateFilters({ ...filters, page });
|
||||
|
||||
@@ -53,21 +112,37 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
});
|
||||
}, 100); // 延迟100ms,确保DOM更新
|
||||
}
|
||||
}, [filters, updateFilters, eventTimelineRef]);
|
||||
}, [filters, updateFilters, eventTimelineRef, track]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((event) => {
|
||||
// 🎯 PostHog 追踪:新闻事件点击
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
event_id: event.id || event.event_id,
|
||||
event_title: event.title,
|
||||
importance: event.importance,
|
||||
source: 'community_page',
|
||||
has_stocks: !!(event.related_stocks && event.related_stocks.length > 0),
|
||||
has_concepts: !!(event.related_concepts && event.related_concepts.length > 0),
|
||||
});
|
||||
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
}, [onEventClick]);
|
||||
}, [onEventClick, track]);
|
||||
|
||||
// 处理查看详情
|
||||
const handleViewDetail = useCallback((eventId) => {
|
||||
// 🎯 PostHog 追踪:查看详情
|
||||
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
event_id: eventId,
|
||||
source: 'community_page',
|
||||
});
|
||||
|
||||
if (navigate) {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
}
|
||||
}, [navigate]);
|
||||
}, [navigate, track]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
|
||||
@@ -20,12 +20,15 @@ import { useEventFilters } from './hooks/useEventFilters';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../lib/constants';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
const Community = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪
|
||||
|
||||
// Redux状态
|
||||
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
|
||||
@@ -59,6 +62,15 @@ const Community = () => {
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// 🎯 PostHog 追踪:页面浏览
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
has_hot_events: hotEvents && hotEvents.length > 0,
|
||||
has_keywords: popularKeywords && popularKeywords.length > 0,
|
||||
});
|
||||
}, [track]); // 只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 首次访问社区时,延迟显示权限引导
|
||||
useEffect(() => {
|
||||
if (showCommunityGuide) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -64,6 +65,17 @@ const ConceptTimelineModal = ({
|
||||
conceptId
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
const {
|
||||
trackDateToggled,
|
||||
trackNewsClicked,
|
||||
trackNewsDetailOpened,
|
||||
trackReportClicked,
|
||||
trackReportDetailOpened,
|
||||
trackModalClosed,
|
||||
} = useConceptTimelineEvents({ conceptName, conceptId, isOpen });
|
||||
|
||||
const [timelineData, setTimelineData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedDates, setExpandedDates] = useState({});
|
||||
@@ -318,6 +330,11 @@ const ConceptTimelineModal = ({
|
||||
|
||||
// 切换日期展开状态
|
||||
const toggleDateExpand = (date) => {
|
||||
const willExpand = !expandedDates[date];
|
||||
|
||||
// 🎯 追踪日期展开/折叠
|
||||
trackDateToggled(date, willExpand);
|
||||
|
||||
setExpandedDates(prev => ({
|
||||
...prev,
|
||||
[date]: !prev[date]
|
||||
@@ -728,6 +745,10 @@ const ConceptTimelineModal = ({
|
||||
leftIcon={<ViewIcon />}
|
||||
onClick={() => {
|
||||
if (event.type === 'news') {
|
||||
// 🎯 追踪新闻点击和详情打开
|
||||
trackNewsClicked(event, date);
|
||||
trackNewsDetailOpened(event);
|
||||
|
||||
setSelectedNews({
|
||||
title: event.title,
|
||||
content: event.content,
|
||||
@@ -737,6 +758,10 @@ const ConceptTimelineModal = ({
|
||||
});
|
||||
setIsNewsModalOpen(true);
|
||||
} else if (event.type === 'report') {
|
||||
// 🎯 追踪研报点击和详情打开
|
||||
trackReportClicked(event, date);
|
||||
trackReportDetailOpened(event);
|
||||
|
||||
setSelectedReport({
|
||||
title: event.title,
|
||||
content: event.content,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { useConceptStatsEvents } from '../hooks/useConceptStatsEvents';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
@@ -54,6 +55,15 @@ const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
? '/concept-api'
|
||||
: 'http://111.198.58.126:16801';
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
const {
|
||||
trackTabChanged,
|
||||
trackTimeRangeChanged,
|
||||
trackCustomDateRangeSet,
|
||||
trackRankItemClicked,
|
||||
trackDataRefreshed,
|
||||
} = useConceptStatsEvents();
|
||||
|
||||
const [statsData, setStatsData] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
@@ -180,10 +190,18 @@ const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
|
||||
setCustomEndDate(today.toISOString().split('T')[0]);
|
||||
setCustomStartDate(weekAgo.toISOString().split('T')[0]);
|
||||
|
||||
// 🎯 追踪切换到自定义范围
|
||||
trackTimeRangeChanged(0, true);
|
||||
} else {
|
||||
setUseCustomRange(false);
|
||||
setTimeRange(parseInt(newRange));
|
||||
fetchStatsData(parseInt(newRange));
|
||||
const days = parseInt(newRange);
|
||||
setTimeRange(days);
|
||||
|
||||
// 🎯 追踪时间范围变化
|
||||
trackTimeRangeChanged(days, false);
|
||||
|
||||
fetchStatsData(days);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,6 +217,10 @@ const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 追踪自定义日期范围设置
|
||||
trackCustomDateRangeSet(customStartDate, customEndDate);
|
||||
|
||||
fetchStatsData(null, customStartDate, customEndDate);
|
||||
}
|
||||
};
|
||||
@@ -848,7 +870,17 @@ const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
|
||||
{/* 主内容卡片 */}
|
||||
<Box bg={bg} borderRadius="xl" border="1px" borderColor={borderColor} shadow="sm" overflow="hidden">
|
||||
<Tabs index={activeTab} onChange={setActiveTab} variant="unstyled" size="sm">
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={(index) => {
|
||||
const tabNames = ['涨幅榜', '跌幅榜', '活跃榜', '波动榜', '连涨榜'];
|
||||
// 🎯 追踪Tab切换
|
||||
trackTabChanged(index, tabNames[index]);
|
||||
setActiveTab(index);
|
||||
}}
|
||||
variant="unstyled"
|
||||
size="sm"
|
||||
>
|
||||
<TabList
|
||||
bg="gray.50"
|
||||
borderBottom="1px"
|
||||
|
||||
292
src/views/Concept/hooks/useConceptEvents.js
Normal file
292
src/views/Concept/hooks/useConceptEvents.js
Normal file
@@ -0,0 +1,292 @@
|
||||
// src/views/Concept/hooks/useConceptEvents.js
|
||||
// 概念中心页面事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 概念中心事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useConceptEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.CONCEPT_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useConceptEvents', '📊 Concept Page Viewed');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念列表数据查看
|
||||
* @param {Array} concepts - 概念列表
|
||||
* @param {Object} filters - 当前筛选条件
|
||||
*/
|
||||
const trackConceptListViewed = useCallback((concepts, filters = {}) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_LIST_VIEWED, {
|
||||
concept_count: concepts.length,
|
||||
sort_by: filters.sortBy,
|
||||
view_mode: filters.viewMode,
|
||||
has_search_query: !!filters.searchQuery,
|
||||
selected_date: filters.selectedDate,
|
||||
page: filters.page,
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📋 Concept List Viewed', {
|
||||
count: concepts.length,
|
||||
filters,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索开始
|
||||
*/
|
||||
const trackSearchInitiated = useCallback(() => {
|
||||
track(RETENTION_EVENTS.SEARCH_INITIATED, {
|
||||
context: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🔍 Search Initiated');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索查询提交
|
||||
* @param {string} query - 搜索查询词
|
||||
* @param {number} resultCount - 搜索结果数量
|
||||
*/
|
||||
const trackSearchQuerySubmitted = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
category: 'concept',
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'concept_center',
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('useConceptEvents', '🔍 Search Query Submitted', {
|
||||
query,
|
||||
resultCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪排序方式变化
|
||||
* @param {string} sortBy - 新的排序方式
|
||||
* @param {string} previousSortBy - 之前的排序方式
|
||||
*/
|
||||
const trackSortChanged = useCallback((sortBy, previousSortBy = null) => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'sort',
|
||||
filter_value: sortBy,
|
||||
previous_value: previousSortBy,
|
||||
context: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🔄 Sort Changed', {
|
||||
sortBy,
|
||||
previousSortBy,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪视图模式切换
|
||||
* @param {string} viewMode - 新的视图模式 (grid/list)
|
||||
* @param {string} previousViewMode - 之前的视图模式
|
||||
*/
|
||||
const trackViewModeChanged = useCallback((viewMode, previousViewMode = null) => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'view_mode',
|
||||
filter_value: viewMode,
|
||||
previous_value: previousViewMode,
|
||||
context: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '👁️ View Mode Changed', {
|
||||
viewMode,
|
||||
previousViewMode,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪日期选择变化
|
||||
* @param {string} newDate - 新选择的日期
|
||||
* @param {string} previousDate - 之前的日期
|
||||
* @param {string} selectionMethod - 选择方式 (today/yesterday/week_ago/month_ago/custom)
|
||||
*/
|
||||
const trackDateChanged = useCallback((newDate, previousDate = null, selectionMethod = 'custom') => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'date',
|
||||
filter_value: newDate,
|
||||
previous_value: previousDate,
|
||||
selection_method: selectionMethod,
|
||||
context: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📅 Date Changed', {
|
||||
newDate,
|
||||
previousDate,
|
||||
selectionMethod,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪分页变化
|
||||
* @param {number} page - 新的页码
|
||||
* @param {Object} filters - 当前筛选条件
|
||||
*/
|
||||
const trackPageChanged = useCallback((page, filters = {}) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_LIST_VIEWED, {
|
||||
page,
|
||||
sort_by: filters.sortBy,
|
||||
view_mode: filters.viewMode,
|
||||
has_search_query: !!filters.searchQuery,
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📄 Page Changed', { page, filters });
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念卡片点击
|
||||
* @param {Object} concept - 概念对象
|
||||
* @param {number} position - 在列表中的位置
|
||||
* @param {string} source - 来源 (list/stats_panel)
|
||||
*/
|
||||
const trackConceptClicked = useCallback((concept, position = 0, source = 'list') => {
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_name: concept.concept_name || concept.name,
|
||||
concept_code: concept.concept_code || concept.code,
|
||||
change_percent: concept.change_pct || concept.change_percent,
|
||||
stock_count: concept.stock_count,
|
||||
position,
|
||||
source,
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🎯 Concept Clicked', {
|
||||
concept: concept.concept_name || concept.name,
|
||||
position,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念下的股票标签点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} conceptName - 所属概念名称
|
||||
*/
|
||||
const trackConceptStockClicked = useCallback((stock, conceptName) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_STOCK_CLICKED, {
|
||||
stock_code: stock.code || stock.stock_code,
|
||||
stock_name: stock.name || stock.stock_name,
|
||||
concept_name: conceptName,
|
||||
source: 'concept_center_tag',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🏷️ Concept Stock Tag Clicked', {
|
||||
stock: stock.code || stock.stock_code,
|
||||
concept: conceptName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念详情查看(时间轴Modal)
|
||||
* @param {string} conceptName - 概念名称
|
||||
* @param {string} conceptId - 概念ID
|
||||
*/
|
||||
const trackConceptDetailViewed = useCallback((conceptName, conceptId) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_DETAIL_VIEWED, {
|
||||
concept_name: conceptName,
|
||||
concept_id: conceptId,
|
||||
source: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📊 Concept Detail Viewed', {
|
||||
conceptName,
|
||||
conceptId,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪股票详情Modal打开
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} stockName - 股票名称
|
||||
*/
|
||||
const trackStockDetailViewed = useCallback((stockCode, stockName) => {
|
||||
track(RETENTION_EVENTS.STOCK_DETAIL_VIEWED, {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
source: 'concept_center_modal',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '👁️ Stock Detail Modal Opened', {
|
||||
stockCode,
|
||||
stockName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪付费墙展示
|
||||
* @param {string} feature - 需要付费的功能
|
||||
* @param {string} requiredTier - 需要的订阅等级
|
||||
*/
|
||||
const trackPaywallShown = useCallback((feature, requiredTier = 'pro') => {
|
||||
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
|
||||
feature,
|
||||
required_tier: requiredTier,
|
||||
page: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🔒 Paywall Shown', {
|
||||
feature,
|
||||
requiredTier,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
* @param {string} feature - 触发升级的功能
|
||||
* @param {string} targetTier - 目标订阅等级
|
||||
*/
|
||||
const trackUpgradeClicked = useCallback((feature, targetTier = 'pro') => {
|
||||
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
|
||||
feature,
|
||||
target_tier: targetTier,
|
||||
source_page: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '⬆️ Upgrade Button Clicked', {
|
||||
feature,
|
||||
targetTier,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
trackConceptListViewed,
|
||||
trackSearchInitiated,
|
||||
trackSearchQuerySubmitted,
|
||||
trackSortChanged,
|
||||
trackViewModeChanged,
|
||||
trackDateChanged,
|
||||
trackPageChanged,
|
||||
trackConceptClicked,
|
||||
trackConceptStockClicked,
|
||||
trackConceptDetailViewed,
|
||||
trackStockDetailViewed,
|
||||
trackPaywallShown,
|
||||
trackUpgradeClicked,
|
||||
};
|
||||
};
|
||||
BIN
src/views/Concept/hooks/useConceptStatsEvents.js
Normal file
BIN
src/views/Concept/hooks/useConceptStatsEvents.js
Normal file
Binary file not shown.
BIN
src/views/Concept/hooks/useConceptTimelineEvents.js
Normal file
BIN
src/views/Concept/hooks/useConceptTimelineEvents.js
Normal file
Binary file not shown.
@@ -90,6 +90,8 @@ import { useSubscription } from '../../hooks/useSubscription';
|
||||
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
|
||||
// 导入市场服务
|
||||
import { marketService } from '../../services/marketService';
|
||||
// 导入 PostHog 追踪 Hook
|
||||
import { useConceptEvents } from './hooks/useConceptEvents';
|
||||
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? '/concept-api'
|
||||
@@ -129,6 +131,18 @@ const ConceptCenter = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
const {
|
||||
trackConceptSearched,
|
||||
trackFilterApplied,
|
||||
trackConceptClicked,
|
||||
trackConceptStocksViewed,
|
||||
trackConceptStockClicked,
|
||||
trackConceptTimelineViewed,
|
||||
trackPageChange,
|
||||
trackViewModeChanged,
|
||||
} = useConceptEvents({ navigate });
|
||||
|
||||
// 订阅权限管理
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
@@ -192,6 +206,9 @@ const ConceptCenter = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 追踪历史时间轴查看
|
||||
trackConceptTimelineViewed(conceptName, conceptId);
|
||||
|
||||
setSelectedConceptForContent(conceptName);
|
||||
setSelectedConceptId(conceptId);
|
||||
setIsTimelineModalOpen(true);
|
||||
@@ -318,8 +335,14 @@ const ConceptCenter = () => {
|
||||
setSortBy('change_pct');
|
||||
}
|
||||
|
||||
// 🎯 追踪搜索查询(在fetchConcepts后追踪结果数量)
|
||||
updateUrlParams({ q: searchQuery, page: 1, sort: newSortBy });
|
||||
fetchConcepts(searchQuery, 1, selectedDate, newSortBy);
|
||||
fetchConcepts(searchQuery, 1, selectedDate, newSortBy).then(() => {
|
||||
if (searchQuery && searchQuery.trim() !== '') {
|
||||
// 使用当前 concepts.length 作为结果数量
|
||||
setTimeout(() => trackConceptSearched(searchQuery, concepts.length), 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理Enter键搜索
|
||||
@@ -331,6 +354,11 @@ const ConceptCenter = () => {
|
||||
|
||||
// 处理排序变化
|
||||
const handleSortChange = (value) => {
|
||||
const previousSort = sortBy;
|
||||
|
||||
// 🎯 追踪排序变化
|
||||
trackFilterApplied('sort', value, previousSort);
|
||||
|
||||
setSortBy(value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ sort: value, page: 1 });
|
||||
@@ -340,6 +368,11 @@ const ConceptCenter = () => {
|
||||
// 处理日期变化
|
||||
const handleDateChange = (e) => {
|
||||
const date = new Date(e.target.value);
|
||||
const previousDate = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||||
|
||||
// 🎯 追踪日期变化
|
||||
trackFilterApplied('date', e.target.value, previousDate);
|
||||
|
||||
setSelectedDate(date);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ date: e.target.value, page: 1 });
|
||||
@@ -359,6 +392,9 @@ const ConceptCenter = () => {
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page) => {
|
||||
// 🎯 追踪翻页
|
||||
trackPageChange(page, { sort: sortBy, q: searchQuery, date: selectedDate?.toISOString().split('T')[0] });
|
||||
|
||||
setCurrentPage(page);
|
||||
updateUrlParams({ page });
|
||||
fetchConcepts(searchQuery, page, selectedDate, sortBy);
|
||||
@@ -366,7 +402,12 @@ const ConceptCenter = () => {
|
||||
};
|
||||
|
||||
// 处理概念点击
|
||||
const handleConceptClick = (conceptId, conceptName) => {
|
||||
const handleConceptClick = (conceptId, conceptName, concept = null, position = 0) => {
|
||||
// 🎯 追踪概念点击
|
||||
if (concept) {
|
||||
trackConceptClicked(concept, position);
|
||||
}
|
||||
|
||||
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(conceptName)}.html`;
|
||||
window.open(htmlPath, '_blank');
|
||||
};
|
||||
@@ -433,6 +474,9 @@ const ConceptCenter = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 追踪查看个股
|
||||
trackConceptStocksViewed(concept.concept, concept.stocks?.length || 0);
|
||||
|
||||
setSelectedConceptStocks(concept.stocks || []);
|
||||
setSelectedConceptName(concept.concept);
|
||||
setStockMarketData({}); // 清空之前的数据
|
||||
@@ -649,7 +693,7 @@ const ConceptCenter = () => {
|
||||
}, []);
|
||||
|
||||
// 概念卡片组件 - 优化版
|
||||
const ConceptCard = ({ concept }) => {
|
||||
const ConceptCard = ({ concept, position = 0 }) => {
|
||||
const changePercent = concept.price_info?.avg_change_pct;
|
||||
const changeColor = getChangeColor(changePercent);
|
||||
const hasChange = changePercent !== null && changePercent !== undefined;
|
||||
@@ -657,7 +701,7 @@ const ConceptCenter = () => {
|
||||
return (
|
||||
<Card
|
||||
cursor="pointer"
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept)}
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept, concept, position)}
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
@@ -857,7 +901,7 @@ const ConceptCenter = () => {
|
||||
};
|
||||
|
||||
// 概念列表项组件 - 列表视图
|
||||
const ConceptListItem = ({ concept }) => {
|
||||
const ConceptListItem = ({ concept, position = 0 }) => {
|
||||
const changePercent = concept.price_info?.avg_change_pct;
|
||||
const changeColor = getChangeColor(changePercent);
|
||||
const hasChange = changePercent !== null && changePercent !== undefined;
|
||||
@@ -865,7 +909,7 @@ const ConceptCenter = () => {
|
||||
return (
|
||||
<Card
|
||||
cursor="pointer"
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept)}
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept, concept, position)}
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
@@ -1361,7 +1405,12 @@ const ConceptCenter = () => {
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<IconButton
|
||||
icon={<FaThLarge />}
|
||||
onClick={() => setViewMode('grid')}
|
||||
onClick={() => {
|
||||
if (viewMode !== 'grid') {
|
||||
trackViewModeChanged('grid', viewMode);
|
||||
setViewMode('grid');
|
||||
}
|
||||
}}
|
||||
bg={viewMode === 'grid' ? 'purple.500' : 'transparent'}
|
||||
color={viewMode === 'grid' ? 'white' : 'purple.500'}
|
||||
borderColor="purple.500"
|
||||
@@ -1370,7 +1419,12 @@ const ConceptCenter = () => {
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FaList />}
|
||||
onClick={() => setViewMode('list')}
|
||||
onClick={() => {
|
||||
if (viewMode !== 'list') {
|
||||
trackViewModeChanged('list', viewMode);
|
||||
setViewMode('list');
|
||||
}
|
||||
}}
|
||||
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
|
||||
color={viewMode === 'list' ? 'white' : 'purple.500'}
|
||||
borderColor="purple.500"
|
||||
@@ -1404,16 +1458,16 @@ const ConceptCenter = () => {
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6} className="concept-grid">
|
||||
{concepts.map((concept) => (
|
||||
{concepts.map((concept, index) => (
|
||||
<Box key={concept.concept_id} className="concept-item" role="group">
|
||||
<ConceptCard concept={concept} />
|
||||
<ConceptCard concept={concept} position={index} />
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<VStack spacing={4} align="stretch" className="concept-list">
|
||||
{concepts.map((concept) => (
|
||||
<ConceptListItem key={concept.concept_id} concept={concept} />
|
||||
{concepts.map((concept, index) => (
|
||||
<ConceptListItem key={concept.concept_id} concept={concept} position={index} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Home/HomePage.js - 专业投资分析平台
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -21,10 +21,13 @@ import heroBg from '../../assets/img/BackgroundCard1.png';
|
||||
import '../../styles/home-animations.css';
|
||||
import { logger } from '../../utils/logger';
|
||||
import MidjourneyHeroSection from '../Community/components/MidjourneyHeroSection';
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { ACQUISITION_EVENTS } from '../../lib/constants';
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, isAuthenticated } = useAuth(); // ⚡ 移除 isLoading,不再依赖它
|
||||
const navigate = useNavigate();
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪
|
||||
const [imageLoaded, setImageLoaded] = React.useState(false);
|
||||
|
||||
// 响应式配置
|
||||
@@ -46,6 +49,15 @@ export default function HomePage() {
|
||||
});
|
||||
}, [user?.id, isAuthenticated]); // 只依赖 user.id,避免无限循环
|
||||
|
||||
// 🎯 PostHog 追踪:页面浏览
|
||||
useEffect(() => {
|
||||
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
is_authenticated: isAuthenticated,
|
||||
user_id: user?.id || null,
|
||||
});
|
||||
}, [track, isAuthenticated, user?.id]);
|
||||
|
||||
// 核心功能配置 - 5个主要功能
|
||||
const coreFeatures = [
|
||||
{
|
||||
@@ -106,15 +118,25 @@ export default function HomePage() {
|
||||
];
|
||||
|
||||
// @TODO 如何区分内部链接和外部链接?
|
||||
const handleProductClick = (url) => {
|
||||
if (url.startsWith('http')) {
|
||||
const handleProductClick = useCallback((feature) => {
|
||||
// 🎯 PostHog 追踪:功能卡片点击
|
||||
track(ACQUISITION_EVENTS.FEATURE_CARD_CLICKED, {
|
||||
feature_id: feature.id,
|
||||
feature_title: feature.title,
|
||||
feature_url: feature.url,
|
||||
is_featured: feature.featured || false,
|
||||
link_type: feature.url.startsWith('http') ? 'external' : 'internal',
|
||||
});
|
||||
|
||||
// 原有导航逻辑
|
||||
if (feature.url.startsWith('http')) {
|
||||
// 外部链接,直接打开
|
||||
window.open(url, '_blank');
|
||||
window.open(feature.url, '_blank');
|
||||
} else {
|
||||
// 内部路由
|
||||
navigate(url);
|
||||
navigate(feature.url);
|
||||
}
|
||||
};
|
||||
}, [track, navigate]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -273,7 +295,7 @@ export default function HomePage() {
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
w={{ base: '100%', md: 'auto' }}
|
||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
||||
onClick={() => handleProductClick(coreFeatures[0])}
|
||||
minH="44px"
|
||||
flexShrink={0}
|
||||
>
|
||||
@@ -305,7 +327,7 @@ export default function HomePage() {
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-2px)'
|
||||
}}
|
||||
onClick={() => handleProductClick(feature.url)}
|
||||
onClick={() => handleProductClick(feature)}
|
||||
minH={{ base: 'auto', md: '180px' }}
|
||||
>
|
||||
<CardBody p={{ base: 5, md: 6 }}>
|
||||
@@ -343,7 +365,7 @@ export default function HomePage() {
|
||||
minH="44px"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleProductClick(feature.url);
|
||||
handleProductClick(feature);
|
||||
}}
|
||||
>
|
||||
使用
|
||||
|
||||
236
src/views/StockOverview/hooks/useStockOverviewEvents.js
Normal file
236
src/views/StockOverview/hooks/useStockOverviewEvents.js
Normal file
@@ -0,0 +1,236 @@
|
||||
// src/views/StockOverview/hooks/useStockOverviewEvents.js
|
||||
// 个股中心页面事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 个股中心事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useStockOverviewEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.STOCK_OVERVIEW_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useStockOverviewEvents', '📊 Stock Overview Page Viewed');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪市场统计数据查看
|
||||
* @param {Object} stats - 市场统计数据
|
||||
*/
|
||||
const trackMarketStatsViewed = useCallback((stats) => {
|
||||
if (!stats) return;
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_LIST_VIEWED, {
|
||||
total_market_cap: stats.total_market_cap,
|
||||
total_volume: stats.total_volume,
|
||||
rising_stocks: stats.rising_count,
|
||||
falling_stocks: stats.falling_count,
|
||||
data_date: stats.date,
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '📈 Market Statistics Viewed', stats);
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪股票搜索开始
|
||||
*/
|
||||
const trackSearchInitiated = useCallback(() => {
|
||||
track(RETENTION_EVENTS.SEARCH_INITIATED, {
|
||||
context: 'stock_overview',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🔍 Search Initiated');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪股票搜索查询
|
||||
* @param {string} query - 搜索查询词
|
||||
* @param {number} resultCount - 搜索结果数量
|
||||
*/
|
||||
const trackStockSearched = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_SEARCHED, {
|
||||
query,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'stock_overview',
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🔍 Stock Searched', {
|
||||
query,
|
||||
resultCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索结果点击
|
||||
* @param {Object} stock - 被点击的股票对象
|
||||
* @param {number} position - 在搜索结果中的位置
|
||||
*/
|
||||
const trackSearchResultClicked = useCallback((stock, position = 0) => {
|
||||
track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
exchange: stock.exchange,
|
||||
position,
|
||||
context: 'stock_overview',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🎯 Search Result Clicked', {
|
||||
stock: stock.code,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念卡片点击
|
||||
* @param {Object} concept - 概念对象
|
||||
* @param {number} rank - 在列表中的排名
|
||||
*/
|
||||
const trackConceptClicked = useCallback((concept, rank = 0) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_name: concept.name,
|
||||
concept_code: concept.code,
|
||||
change_percent: concept.change_percent,
|
||||
stock_count: concept.stock_count,
|
||||
rank,
|
||||
source: 'daily_hot_concepts',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🔥 Concept Clicked', {
|
||||
concept: concept.name,
|
||||
rank,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念下的股票标签点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} conceptName - 所属概念名称
|
||||
*/
|
||||
const trackConceptStockClicked = useCallback((stock, conceptName) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
concept_name: conceptName,
|
||||
source: 'daily_hot_concepts_tag',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🏷️ Concept Stock Tag Clicked', {
|
||||
stock: stock.code,
|
||||
concept: conceptName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪热力图中股票点击
|
||||
* @param {Object} stock - 被点击的股票对象
|
||||
* @param {string} marketCapRange - 市值区间
|
||||
*/
|
||||
const trackHeatmapStockClicked = useCallback((stock, marketCapRange = '') => {
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
change_percent: stock.change_percent,
|
||||
market_cap_range: marketCapRange,
|
||||
source: 'market_heatmap',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '📊 Heatmap Stock Clicked', {
|
||||
stock: stock.code,
|
||||
marketCapRange,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪股票详情查看
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} source - 来源(search/concept/heatmap)
|
||||
*/
|
||||
const trackStockDetailViewed = useCallback((stockCode, source = 'unknown') => {
|
||||
track(RETENTION_EVENTS.STOCK_DETAIL_VIEWED, {
|
||||
stock_code: stockCode,
|
||||
source: `stock_overview_${source}`,
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '👁️ Stock Detail Viewed', {
|
||||
stockCode,
|
||||
source,
|
||||
});
|
||||
|
||||
// 导航到公司详情页
|
||||
if (navigate) {
|
||||
navigate(`/company/${stockCode}`);
|
||||
}
|
||||
}, [track, navigate]);
|
||||
|
||||
/**
|
||||
* 追踪概念详情查看
|
||||
* @param {string} conceptCode - 概念代码
|
||||
*/
|
||||
const trackConceptDetailViewed = useCallback((conceptCode) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_DETAIL_VIEWED, {
|
||||
concept_code: conceptCode,
|
||||
source: 'stock_overview_daily_hot',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🎯 Concept Detail Viewed', {
|
||||
conceptCode,
|
||||
});
|
||||
|
||||
// 导航到概念详情页
|
||||
if (navigate) {
|
||||
navigate(`/concept-detail/${conceptCode}`);
|
||||
}
|
||||
}, [track, navigate]);
|
||||
|
||||
/**
|
||||
* 追踪日期选择变化
|
||||
* @param {string} newDate - 新选择的日期
|
||||
* @param {string} previousDate - 之前的日期
|
||||
*/
|
||||
const trackDateChanged = useCallback((newDate, previousDate = null) => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'date',
|
||||
filter_value: newDate,
|
||||
previous_value: previousDate,
|
||||
context: 'stock_overview',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '📅 Date Changed', {
|
||||
newDate,
|
||||
previousDate,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
trackMarketStatsViewed,
|
||||
trackSearchInitiated,
|
||||
trackStockSearched,
|
||||
trackSearchResultClicked,
|
||||
trackConceptClicked,
|
||||
trackConceptStockClicked,
|
||||
trackHeatmapStockClicked,
|
||||
trackStockDetailViewed,
|
||||
trackConceptDetailViewed,
|
||||
trackDateChanged,
|
||||
};
|
||||
};
|
||||
@@ -61,6 +61,7 @@ import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
||||
// Navigation bar now provided by MainLayout
|
||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
|
||||
@@ -83,6 +84,20 @@ const StockOverview = () => {
|
||||
const heatmapRef = useRef(null);
|
||||
const heatmapChart = useRef(null);
|
||||
|
||||
// 🎯 事件追踪 Hook
|
||||
const {
|
||||
trackMarketStatsViewed,
|
||||
trackSearchInitiated,
|
||||
trackStockSearched,
|
||||
trackSearchResultClicked,
|
||||
trackConceptClicked,
|
||||
trackConceptStockClicked,
|
||||
trackHeatmapStockClicked,
|
||||
trackStockDetailViewed,
|
||||
trackConceptDetailViewed,
|
||||
trackDateChanged,
|
||||
} = useStockOverviewEvents({ navigate });
|
||||
|
||||
// 状态管理
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
@@ -141,11 +156,18 @@ const StockOverview = () => {
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setSearchResults(data.data || []);
|
||||
const results = data.data || [];
|
||||
setSearchResults(results);
|
||||
setShowResults(true);
|
||||
|
||||
// 🎯 追踪搜索查询
|
||||
trackStockSearched(query, results.length);
|
||||
} else {
|
||||
logger.warn('StockOverview', '搜索失败', data.error || '请稍后重试', { query });
|
||||
// ❌ 移除搜索失败 toast(非关键操作)
|
||||
|
||||
// 🎯 追踪搜索无结果
|
||||
trackStockSearched(query, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('StockOverview', 'searchStocks', error, { query });
|
||||
@@ -219,18 +241,23 @@ const StockOverview = () => {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMarketStats(prevStats => ({
|
||||
const newStats = {
|
||||
...data.summary,
|
||||
// 保留之前从 heatmap 接口获取的上涨/下跌家数
|
||||
rising_count: prevStats?.rising_count,
|
||||
falling_count: prevStats?.falling_count
|
||||
}));
|
||||
falling_count: prevStats?.falling_count,
|
||||
date: data.trade_date
|
||||
};
|
||||
setMarketStats(newStats);
|
||||
setAvailableDates(data.available_dates || []);
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
logger.debug('StockOverview', '市场统计数据加载成功', {
|
||||
date: data.trade_date,
|
||||
availableDatesCount: data.available_dates?.length || 0
|
||||
});
|
||||
|
||||
// 🎯 追踪市场统计数据查看
|
||||
trackMarketStatsViewed(newStats);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('StockOverview', 'fetchMarketStats', error, { date });
|
||||
@@ -403,6 +430,16 @@ const StockOverview = () => {
|
||||
heatmapChart.current.on('click', function(params) {
|
||||
// 只有点击个股(有code的节点)才跳转
|
||||
if (params.data && params.data.code && !params.data.children) {
|
||||
const stock = {
|
||||
code: params.data.code,
|
||||
name: params.data.name,
|
||||
change_percent: params.data.change
|
||||
};
|
||||
const marketCapRange = getMarketCapRange(params.data.value);
|
||||
|
||||
// 🎯 追踪热力图股票点击
|
||||
trackHeatmapStockClicked(stock, marketCapRange);
|
||||
|
||||
navigate(`/company?scode=${params.data.code}`);
|
||||
}
|
||||
});
|
||||
@@ -412,7 +449,7 @@ const StockOverview = () => {
|
||||
});
|
||||
// ❌ 移除热力图渲染失败 toast(非关键操作)
|
||||
}
|
||||
}, [colorMode, goldColor, navigate]); // ✅ 移除 toast 依赖
|
||||
}, [colorMode, goldColor, navigate, trackHeatmapStockClicked]); // ✅ 添加追踪函数依赖
|
||||
|
||||
// 获取市值区间
|
||||
const getMarketCapRange = (cap) => {
|
||||
@@ -427,6 +464,12 @@ const StockOverview = () => {
|
||||
const handleSearchChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
|
||||
// 🎯 追踪搜索开始(首次输入时)
|
||||
if (value && !searchQuery) {
|
||||
trackSearchInitiated();
|
||||
}
|
||||
|
||||
debounceSearch(value);
|
||||
};
|
||||
|
||||
@@ -438,19 +481,30 @@ const StockOverview = () => {
|
||||
};
|
||||
|
||||
// 选择股票
|
||||
const handleSelectStock = (stock) => {
|
||||
const handleSelectStock = (stock, index = 0) => {
|
||||
// 🎯 追踪搜索结果点击
|
||||
trackSearchResultClicked(stock, index);
|
||||
|
||||
navigate(`/company?scode=${stock.stock_code}`);
|
||||
handleClearSearch();
|
||||
};
|
||||
|
||||
// 查看概念详情(模仿概念中心:打开对应HTML页)
|
||||
const handleConceptClick = (conceptId, conceptName) => {
|
||||
const htmlPath = `/htmls/${conceptName}.html`;
|
||||
const handleConceptClick = (concept, rank = 0) => {
|
||||
// 🎯 追踪概念点击
|
||||
trackConceptClicked(concept, rank);
|
||||
|
||||
const htmlPath = `/htmls/${concept.concept_name}.html`;
|
||||
window.open(htmlPath, '_blank');
|
||||
};
|
||||
|
||||
// 处理日期选择
|
||||
const handleDateChange = (date) => {
|
||||
const previousDate = selectedDate;
|
||||
|
||||
// 🎯 追踪日期变化
|
||||
trackDateChanged(date, previousDate);
|
||||
|
||||
setSelectedDate(date);
|
||||
setIsCalendarOpen(false);
|
||||
// 重新获取数据
|
||||
@@ -661,7 +715,7 @@ const StockOverview = () => {
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: hoverBg }}
|
||||
onClick={() => handleSelectStock(stock)}
|
||||
onClick={() => handleSelectStock(stock, index)}
|
||||
borderBottomWidth={index < searchResults.length - 1 ? "1px" : "0"}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
@@ -880,7 +934,7 @@ const StockOverview = () => {
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
cursor="pointer"
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept_name)}
|
||||
onClick={() => handleConceptClick(concept, index)}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
@@ -951,6 +1005,13 @@ const StockOverview = () => {
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 🎯 追踪概念下的股票标签点击
|
||||
trackConceptStockClicked({
|
||||
code: stock.stock_code,
|
||||
name: stock.stock_name
|
||||
}, concept.concept_name);
|
||||
|
||||
navigate(`/company?scode=${stock.stock_code}`);
|
||||
}}
|
||||
>
|
||||
@@ -969,7 +1030,7 @@ const StockOverview = () => {
|
||||
rightIcon={<FaChevronRight />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConceptClick(concept.concept_id, concept.concept_name);
|
||||
handleConceptClick(concept, index);
|
||||
}}
|
||||
>
|
||||
查看详情
|
||||
|
||||
Reference in New Issue
Block a user