diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1f2edbf5..95814f11 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "Bash(npm install)", "Bash(npm run start:mock)", "Bash(npm install fsevents@latest --save-optional --force)", - "Bash(python -m py_compile:*)" + "Bash(python -m py_compile:*)", + "Bash(ps -p 20502,53360 -o pid,command)" ], "deny": [], "ask": [] diff --git a/.env.development b/.env.development index a7e93534..7ff74018 100644 --- a/.env.development +++ b/.env.development @@ -1,5 +1,5 @@ # 开发环境配置(连接真实后端) -# 使用方式: npm start +# 使用方式: npm run start:dev # React 构建优化配置 GENERATE_SOURCEMAP=false @@ -18,3 +18,10 @@ REACT_APP_ENABLE_MOCK=false # 开发环境标识 REACT_APP_ENV=development + +# PostHog 配置(开发环境) +# 留空 = 仅控制台 debug +# 填入 Key = 控制台 + PostHog Cloud 双模式 +REACT_APP_POSTHOG_KEY= +REACT_APP_POSTHOG_HOST=https://app.posthog.com +REACT_APP_ENABLE_SESSION_RECORDING=false diff --git a/.env.mock b/.env.mock index 6888e0d3..d185935c 100644 --- a/.env.mock +++ b/.env.mock @@ -35,3 +35,14 @@ REACT_APP_ENABLE_MOCK=true # Mock 环境标识 REACT_APP_ENV=mock + +# PostHog 配置(Mock 环境) +# 留空 = 仅控制台 debug +# 填入 Key = 控制台 + PostHog Cloud 双模式 +REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF +REACT_APP_POSTHOG_HOST=https://app.posthog.com +REACT_APP_ENABLE_SESSION_RECORDING=false + +# PostHog Debug 模式(Mock 环境永久启用) +# 在浏览器 Console 中打印详细的事件追踪日志 +REACT_APP_POSTHOG_DEBUG=true diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..1852f4a4 --- /dev/null +++ b/.env.test @@ -0,0 +1,42 @@ +# ======================================== +# 本地测试环境(前后端都在本地) +# ======================================== +# 使用方式: npm run start:test +# +# 工作原理: +# 1. concurrently 同时启动前端和后端 +# 2. 前端: localhost:3000 +# 3. 后端: localhost:5001 (python app_2.py) +# 4. 数据: 本地数据库 +# +# 适用场景: +# - 调试后端代码 +# - 性能测试 +# - 离线开发 +# - 数据库调试 +# ======================================== + +# 环境标识 +REACT_APP_ENV=test +NODE_ENV=development + +# Mock 配置(关闭 MSW) +REACT_APP_ENABLE_MOCK=false + +# 后端 API 地址(本地后端) +REACT_APP_API_URL=http://localhost:5001 + +# PostHog 配置(测试环境) +# 留空 = 仅控制台 debug +# 填入 Key = 控制台 + PostHog Cloud 双模式 +REACT_APP_POSTHOG_KEY= +REACT_APP_POSTHOG_HOST=https://app.posthog.com +REACT_APP_ENABLE_SESSION_RECORDING=false + +# React 构建优化配置 +GENERATE_SOURCEMAP=true # 测试环境保留 sourcemap 便于调试 +SKIP_PREFLIGHT_CHECK=true +DISABLE_ESLINT_PLUGIN=false # 测试环境开启 ESLint +TSC_COMPILE_ON_ERROR=true +IMAGE_INLINE_SIZE_LIMIT=10000 +NODE_OPTIONS=--max_old_space_size=4096 diff --git a/package.json b/package.json index a5499614..ff6605f4 100755 --- a/package.json +++ b/package.json @@ -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", @@ -92,9 +93,14 @@ "uuid": "^9.0.1" }, "scripts": { - "start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco start", - "start:mock": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start", + "prestart": "kill-port 3000", + "start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start", + "start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start", "start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start", + "start:test": "concurrently \"python app_2.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"", + "frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start", + "dev": "npm start", + "backend": "python app_2.py", "build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco build && gulp licenses", "build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build", "test": "craco test --env=jsdom", @@ -104,12 +110,14 @@ "rollback": "bash scripts/rollback-from-local.sh", "lint:check": "eslint . --ext=js,jsx; exit 0", "lint:fix": "eslint . --ext=js,jsx --fix; exit 0", - "install:clean": "rm -rf node_modules/ && rm -rf package-lock.json && npm install && npm start" + "clean": "rm -rf node_modules/ package-lock.json", + "reinstall": "npm run clean && npm install" }, "devDependencies": { "@craco/craco": "^7.1.0", "ajv": "^8.17.1", "autoprefixer": "^10.4.21", + "concurrently": "^8.2.2", "env-cmd": "^11.0.0", "eslint-config-prettier": "8.3.0", "eslint-plugin-prettier": "3.4.0", @@ -118,6 +126,7 @@ "imagemin": "^9.0.1", "imagemin-mozjpeg": "^10.0.0", "imagemin-pngquant": "^10.0.0", + "kill-port": "^2.0.1", "msw": "^2.11.5", "postcss": "^8.5.6", "prettier": "2.2.1", diff --git a/src/App.js b/src/App.js index 64e84621..33695b9c 100755 --- a/src/App.js +++ b/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 ( diff --git a/src/components/Auth/AuthFormContent.js b/src/components/Auth/AuthFormContent.js index 6de1fa70..8435c597 100644 --- a/src/components/Auth/AuthFormContent.js +++ b/src/components/Auth/AuthFormContent.js @@ -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); + } }; // 倒计时逻辑 @@ -147,6 +164,8 @@ export default function AuthFormContent() { const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, ''); if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) { + authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format'); + authEvents.trackFormValidationError('phone', 'invalid_format', '请输入有效的手机号'); toast({ title: "请输入有效的手机号", status: "warning", @@ -155,6 +174,9 @@ export default function AuthFormContent() { return; } + // 追踪手机号验证通过 + authEvents.trackPhoneNumberValidated(credential, true); + try { setSendingCode(true); @@ -190,6 +212,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: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7), @@ -207,6 +237,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: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7) }); @@ -262,6 +299,9 @@ export default function AuthFormContent() { return; } + // 追踪验证码提交 + authEvents.trackVerificationCodeSubmitted(phone); + // 构建请求体 const requestBody = { credential: cleanedPhone, // 使用清理后的手机号 @@ -316,6 +356,9 @@ export default function AuthFormContent() { // 更新session await checkSession(); + // 追踪登录成功并识别用户 + authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser); + // ✅ 保留登录成功 toast(关键操作提示) toast({ title: data.isNewUser ? '注册成功' : '登录成功', @@ -335,6 +378,8 @@ export default function AuthFormContent() { setTimeout(() => { setCurrentPhone(phone); setShowNicknamePrompt(true); + // 追踪昵称设置引导显示 + authEvents.trackNicknamePromptShown(phone); }, config.features.successDelay); } else { // 已有用户,直接登录成功 @@ -355,6 +400,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 @@ -382,6 +436,9 @@ export default function AuthFormContent() { // 微信H5登录处理 const handleWechatH5Login = async () => { + // 追踪用户选择微信登录 + authEvents.trackWechatLoginInitiated('icon_button'); + try { // 1. 构建回调URL const redirectUrl = `${window.location.origin}/home/wechat-callback`; @@ -402,11 +459,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: "跳转失败", @@ -418,14 +483,17 @@ export default function AuthFormContent() { } }; - // 组件卸载时清理 + // 组件挂载时追踪页面浏览 useEffect(() => { isMountedRef.current = true; + // 追踪登录页面浏览 + authEvents.trackLoginPageViewed(); + return () => { isMountedRef.current = false; }; - }, []); + }, [authEvents]); return ( <> @@ -485,6 +553,7 @@ export default function AuthFormContent() { color="blue.500" textDecoration="underline" _hover={{ color: "blue.600" }} + onClick={authEvents.trackUserAgreementClicked} > 《用户协议》 @@ -497,6 +566,7 @@ export default function AuthFormContent() { color="blue.500" textDecoration="underline" _hover={{ color: "blue.600" }} + onClick={authEvents.trackPrivacyPolicyClicked} > 《隐私政策》 @@ -524,8 +594,30 @@ export default function AuthFormContent() { 完善个人信息 您已成功注册!是否前往个人资料设置昵称和其他信息? - { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }}>稍后再说 - { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); setTimeout(() => { navigate('/home/profile'); }, 300); }} ml={3}>去设置 + { + authEvents.trackNicknamePromptSkipped(); + setShowNicknamePrompt(false); + handleLoginSuccess({ phone: currentPhone }); + }} + > + 稍后再说 + + { + authEvents.trackNicknamePromptAccepted(); + setShowNicknamePrompt(false); + handleLoginSuccess({ phone: currentPhone }); + setTimeout(() => { + navigate('/home/profile'); + }, 300); + }} + ml={3} + > + 去设置 + diff --git a/src/components/Auth/WechatRegister.js b/src/components/Auth/WechatRegister.js index 54ff7b99..1c144ae5 100644 --- a/src/components/Auth/WechatRegister.js +++ b/src/components/Auth/WechatRegister.js @@ -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秒 @@ -53,6 +54,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(""); @@ -128,6 +135,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) { @@ -150,10 +164,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]); /** * 检查微信扫码状态 @@ -193,6 +213,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); // 处理成功状态 @@ -205,6 +235,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) { @@ -297,6 +330,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(); @@ -312,6 +355,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); @@ -326,6 +376,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 || "请稍后重试"); @@ -335,7 +390,7 @@ export default function WechatRegister() { setIsLoading(false); } } - }, [startPolling, showError]); + }, [startPolling, showError, wechatSessionId, authEvents]); /** * 安全的按钮点击处理,确保所有错误都被捕获,防止被 ErrorBoundary 捕获 diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index 689b84cd..288b3b0c 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -51,6 +51,7 @@ import SubscriptionButton from '../Subscription/SubscriptionButton'; import SubscriptionModal from '../Subscription/SubscriptionModal'; import { CrownIcon, TooltipContent } from '../Subscription/CrownTooltip'; import InvestmentCalendar from '../../views/Community/components/InvestmentCalendar'; +import { useNavigationEvents } from '../../hooks/useNavigationEvents'; /** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */ const SecondaryNav = ({ showCompletenessAlert }) => { @@ -61,6 +62,9 @@ const SecondaryNav = ({ showCompletenessAlert }) => { // ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用) const borderColorValue = useColorModeValue('gray.200', 'gray.600'); + // 🎯 初始化导航埋点Hook + const navEvents = useNavigationEvents({ component: 'secondary_nav' }); + // 定义二级导航结构 const secondaryNavConfig = { '/community': { @@ -162,7 +166,11 @@ const SecondaryNav = ({ showCompletenessAlert }) => { ) : ( navigate(item.path)} + onClick={() => { + // 🎯 追踪侧边栏菜单点击 + navEvents.trackSidebarMenuClicked(item.label, item.path, 2, false); + navigate(item.path); + }} size="sm" variant="ghost" bg={isActive ? 'blue.50' : 'transparent'} @@ -313,6 +321,9 @@ const NavItems = ({ isAuthenticated, user }) => { // ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用) const contactTextColor = useColorModeValue('gray.500', 'gray.300'); + // 🎯 初始化导航埋点Hook + const navEvents = useNavigationEvents({ component: 'top_nav' }); + // 辅助函数:判断导航项是否激活 const isActive = useCallback((paths) => { return paths.some(path => location.pathname.includes(path)); @@ -337,7 +348,11 @@ const NavItems = ({ isAuthenticated, user }) => { navigate('/community')} + onClick={() => { + // 🎯 追踪菜单项点击 + navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community'); + navigate('/community'); + }} borderRadius="md" bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'} borderLeft={location.pathname.includes('/community') ? '3px solid' : 'none'} @@ -353,7 +368,11 @@ const NavItems = ({ isAuthenticated, user }) => { navigate('/concepts')} + onClick={() => { + // 🎯 追踪菜单项点击 + navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts'); + navigate('/concepts'); + }} borderRadius="md" bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'} borderLeft={location.pathname.includes('/concepts') ? '3px solid' : 'none'} @@ -489,6 +508,9 @@ export default function HomeNavbar() { const brandHover = useColorModeValue('blue.600', 'blue.300'); const toast = useToast(); + // 🎯 初始化导航埋点Hook + const navEvents = useNavigationEvents({ component: 'main_navbar' }); + // ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环 const userId = user?.id; const prevUserIdRef = React.useRef(userId); @@ -882,7 +904,11 @@ export default function HomeNavbar() { color={brandText} cursor="pointer" _hover={{ color: brandHover }} - onClick={() => navigate('/home')} + onClick={() => { + // 🎯 追踪Logo点击 + navEvents.trackLogoClicked(); + navigate('/home'); + }} style={{ minWidth: isMobile ? '100px' : '140px' }} noOfLines={1} > @@ -912,7 +938,13 @@ export default function HomeNavbar() { : } - onClick={toggleColorMode} + onClick={() => { + // 🎯 追踪主题切换 + const fromTheme = colorMode; + const toTheme = colorMode === 'light' ? 'dark' : 'light'; + navEvents.trackThemeChanged(fromTheme, toTheme); + toggleColorMode(); + }} variant="ghost" size="sm" minW={{ base: '36px', md: '40px' }} diff --git a/src/components/PostHogProvider.js b/src/components/PostHogProvider.js new file mode 100644 index 00000000..0ed48309 --- /dev/null +++ b/src/components/PostHogProvider.js @@ -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: + * + * + * + */ +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; diff --git a/src/components/RiskDisclaimer/RiskDisclaimer.js b/src/components/RiskDisclaimer/RiskDisclaimer.js new file mode 100644 index 00000000..3fcd1a43 --- /dev/null +++ b/src/components/RiskDisclaimer/RiskDisclaimer.js @@ -0,0 +1,60 @@ +// src/components/RiskDisclaimer/RiskDisclaimer.js +import React from 'react'; +import { Box, Text, HStack, Icon, useColorModeValue } from '@chakra-ui/react'; + +/** + * 风险提示组件 + * + * @param {Object} props + * @param {string} props.text - 风险提示文本内容 + * @param {string} props.variant - 文本变体类型 ('default', 'homepage', 'section') + * @param {Object} props.sx - 额外的样式对象 + */ +const RiskDisclaimer = ({ + text, + variant = 'default', + sx = {}, + mt = 0, + mb = 0, + ...rest +}) => { + // 极简风格 - 透明背景,固定灰色文字 + const textColor = '#999999'; // 固定中性灰,不受主题影响 + + // 预定义的文本变体 + const textVariants = { + homepage: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。站内所有文章均不构成投资建议,请投资者注意风险,独立审慎决策。', + default: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。本产品内容均不构成投资建议,请投资者注意风险,独立审慎决策。', + section: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。本部分产品内容均不构成投资建议,请投资者注意风险,独立审慎决策。' + }; + + // 使用传入的text或预定义的variant + const displayText = text || textVariants[variant] || textVariants.default; + + return ( + + + + {displayText} + + + + ); +}; + +export default RiskDisclaimer; diff --git a/src/components/RiskDisclaimer/index.js b/src/components/RiskDisclaimer/index.js new file mode 100644 index 00000000..cb294116 --- /dev/null +++ b/src/components/RiskDisclaimer/index.js @@ -0,0 +1,2 @@ +// src/components/RiskDisclaimer/index.js +export { default } from './RiskDisclaimer'; diff --git a/src/components/StockChart/StockChartAntdModal.js b/src/components/StockChart/StockChartAntdModal.js index 2f84ef90..0c5758fa 100644 --- a/src/components/StockChart/StockChartAntdModal.js +++ b/src/components/StockChart/StockChartAntdModal.js @@ -7,6 +7,7 @@ import moment from 'moment'; import { stockService } from '../../services/eventService'; import CitedContent from '../Citation/CitedContent'; import { logger } from '../../utils/logger'; +import RiskDisclaimer from '../RiskDisclaimer'; const { Text } = Typography; @@ -563,19 +564,8 @@ const StockChartAntdModal = ({ ) : null} - {/* 调试信息 */} - {process.env.NODE_ENV === 'development' && chartData && ( - - 调试信息: - 数据条数: {chartData.data ? chartData.data.length : 0} - - 交易日期: {chartData.trade_date} - - 图表类型: {activeChartType} - - 原始事件时间: {eventTime} - - )} + {/* 风险提示 */} + ); diff --git a/src/components/StockChart/StockChartModal.js b/src/components/StockChart/StockChartModal.js index d83fd558..afcd7f9e 100644 --- a/src/components/StockChart/StockChartModal.js +++ b/src/components/StockChart/StockChartModal.js @@ -6,6 +6,7 @@ import * as echarts from 'echarts'; import moment from 'moment'; import { stockService } from '../../services/eventService'; import { logger } from '../../utils/logger'; +import RiskDisclaimer from '../RiskDisclaimer'; const StockChartModal = ({ isOpen, @@ -545,6 +546,11 @@ const StockChartModal = ({ )} + {/* 风险提示 */} + + + + {process.env.NODE_ENV === 'development' && chartData && ( 调试信息: diff --git a/src/components/Subscription/SubscriptionContent.js b/src/components/Subscription/SubscriptionContent.js index 7131883f..79c9b351 100644 --- a/src/components/Subscription/SubscriptionContent.js +++ b/src/components/Subscription/SubscriptionContent.js @@ -33,6 +33,7 @@ import { import React, { useState, useEffect } from 'react'; import { logger } from '../../utils/logger'; import { useAuth } from '../../contexts/AuthContext'; +import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents'; // Icons import { @@ -54,6 +55,14 @@ export default function SubscriptionContent() { // Auth context const { user } = useAuth(); + // 🎯 初始化订阅埋点Hook(传入当前订阅信息) + const subscriptionEvents = useSubscriptionEvents({ + currentSubscription: { + plan: user?.subscription_plan || 'free', + status: user?.subscription_status || 'none' + } + }); + // Chakra color mode const textColor = useColorModeValue('gray.700', 'white'); const borderColor = useColorModeValue('gray.200', 'gray.600'); @@ -161,6 +170,13 @@ export default function SubscriptionContent() { return; } + // 🎯 追踪定价方案选择 + subscriptionEvents.trackPricingPlanSelected( + plan.name, + selectedCycle, + selectedCycle === 'monthly' ? plan.monthly_price : plan.yearly_price + ); + setSelectedPlan(plan); onPaymentModalOpen(); }; @@ -170,6 +186,17 @@ export default function SubscriptionContent() { setLoading(true); try { + const price = selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price; + + // 🎯 追踪支付发起 + subscriptionEvents.trackPaymentInitiated({ + planName: selectedPlan.name, + paymentMethod: 'wechat_pay', + amount: price, + billingCycle: selectedCycle, + orderId: null // Will be set after order creation + }); + const response = await fetch('/api/payment/create-order', { method: 'POST', headers: { @@ -204,6 +231,13 @@ export default function SubscriptionContent() { throw new Error('网络错误'); } } catch (error) { + // 🎯 追踪支付失败 + subscriptionEvents.trackPaymentFailed({ + planName: selectedPlan.name, + paymentMethod: 'wechat_pay', + amount: selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price + }, error.message); + toast({ title: '创建订单失败', description: error.message, @@ -251,6 +285,26 @@ export default function SubscriptionContent() { setAutoCheckInterval(null); logger.info('SubscriptionContent', '自动检测到支付成功', { orderId }); + + // 🎯 追踪支付成功 + subscriptionEvents.trackPaymentSuccessful({ + planName: selectedPlan?.name, + paymentMethod: 'wechat_pay', + amount: paymentOrder?.amount, + billingCycle: selectedCycle, + orderId: orderId, + transactionId: data.transaction_id + }); + + // 🎯 追踪订阅创建 + subscriptionEvents.trackSubscriptionCreated({ + plan: selectedPlan?.name, + billingCycle: selectedCycle, + amount: paymentOrder?.amount, + startDate: new Date().toISOString(), + endDate: null // Will be calculated by backend + }); + toast({ title: '支付成功!', description: '订阅已激活,正在跳转...', diff --git a/src/hooks/useAuthEvents.js b/src/hooks/useAuthEvents.js new file mode 100644 index 00000000..db7a4bfc --- /dev/null +++ b/src/hooks/useAuthEvents.js @@ -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; diff --git a/src/hooks/useDashboardEvents.js b/src/hooks/useDashboardEvents.js new file mode 100644 index 00000000..44253538 --- /dev/null +++ b/src/hooks/useDashboardEvents.js @@ -0,0 +1,325 @@ +// src/hooks/useDashboardEvents.js +// 个人中心(Dashboard/Center)事件追踪 Hook + +import { useCallback, useEffect } from 'react'; +import { usePostHogTrack } from './usePostHogRedux'; +import { RETENTION_EVENTS } from '../lib/constants'; +import { logger } from '../utils/logger'; + +/** + * 个人中心事件追踪 Hook + * @param {Object} options - 配置选项 + * @param {string} options.pageType - 页面类型 ('center' | 'profile' | 'settings') + * @param {Function} options.navigate - 路由导航函数 + * @returns {Object} 事件追踪处理函数集合 + */ +export const useDashboardEvents = ({ pageType = 'center', navigate } = {}) => { + const { track } = usePostHogTrack(); + + // 🎯 页面浏览事件 - 页面加载时触发 + useEffect(() => { + const eventMap = { + 'center': RETENTION_EVENTS.DASHBOARD_CENTER_VIEWED, + 'profile': RETENTION_EVENTS.PROFILE_PAGE_VIEWED, + 'settings': RETENTION_EVENTS.SETTINGS_PAGE_VIEWED, + }; + + const eventName = eventMap[pageType] || RETENTION_EVENTS.DASHBOARD_VIEWED; + + track(eventName, { + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', `📊 Dashboard Page Viewed: ${pageType}`); + }, [track, pageType]); + + /** + * 追踪功能卡片点击 + * @param {string} cardName - 卡片名称 ('watchlist' | 'following_events' | 'comments' | 'subscription') + * @param {Object} cardData - 卡片数据 + */ + const trackFunctionCardClicked = useCallback((cardName, cardData = {}) => { + if (!cardName) { + logger.warn('useDashboardEvents', 'Card name is required'); + return; + } + + track(RETENTION_EVENTS.FUNCTION_CARD_CLICKED, { + card_name: cardName, + data_count: cardData.count || 0, + has_data: Boolean(cardData.count && cardData.count > 0), + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '🎴 Function Card Clicked', { + cardName, + count: cardData.count, + }); + }, [track]); + + /** + * 追踪自选股列表查看 + * @param {number} stockCount - 自选股数量 + * @param {boolean} hasRealtime - 是否有实时行情 + */ + const trackWatchlistViewed = useCallback((stockCount = 0, hasRealtime = false) => { + track('Watchlist Viewed', { + stock_count: stockCount, + has_realtime: hasRealtime, + is_empty: stockCount === 0, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '⭐ Watchlist Viewed', { + stockCount, + hasRealtime, + }); + }, [track]); + + /** + * 追踪自选股点击 + * @param {Object} stock - 股票对象 + * @param {string} stock.code - 股票代码 + * @param {string} stock.name - 股票名称 + * @param {number} position - 在列表中的位置 + */ + const trackWatchlistStockClicked = useCallback((stock, position = 0) => { + if (!stock || !stock.code) { + logger.warn('useDashboardEvents', 'Stock object is required'); + return; + } + + track(RETENTION_EVENTS.STOCK_CLICKED, { + stock_code: stock.code, + stock_name: stock.name || '', + source: 'watchlist', + position, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '🎯 Watchlist Stock Clicked', { + stockCode: stock.code, + position, + }); + }, [track]); + + /** + * 追踪自选股添加 + * @param {Object} stock - 股票对象 + * @param {string} stock.code - 股票代码 + * @param {string} stock.name - 股票名称 + * @param {string} source - 来源 ('search' | 'stock_detail' | 'manual') + */ + const trackWatchlistStockAdded = useCallback((stock, source = 'manual') => { + if (!stock || !stock.code) { + logger.warn('useDashboardEvents', 'Stock object is required'); + return; + } + + track('Watchlist Stock Added', { + stock_code: stock.code, + stock_name: stock.name || '', + source, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '➕ Watchlist Stock Added', { + stockCode: stock.code, + source, + }); + }, [track]); + + /** + * 追踪自选股移除 + * @param {Object} stock - 股票对象 + * @param {string} stock.code - 股票代码 + * @param {string} stock.name - 股票名称 + */ + const trackWatchlistStockRemoved = useCallback((stock) => { + if (!stock || !stock.code) { + logger.warn('useDashboardEvents', 'Stock object is required'); + return; + } + + track('Watchlist Stock Removed', { + stock_code: stock.code, + stock_name: stock.name || '', + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '➖ Watchlist Stock Removed', { + stockCode: stock.code, + }); + }, [track]); + + /** + * 追踪关注的事件列表查看 + * @param {number} eventCount - 关注的事件数量 + */ + const trackFollowingEventsViewed = useCallback((eventCount = 0) => { + track('Following Events Viewed', { + event_count: eventCount, + is_empty: eventCount === 0, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '📌 Following Events Viewed', { + eventCount, + }); + }, [track]); + + /** + * 追踪关注的事件点击 + * @param {Object} event - 事件对象 + * @param {number} event.id - 事件ID + * @param {string} event.title - 事件标题 + * @param {number} position - 在列表中的位置 + */ + const trackFollowingEventClicked = useCallback((event, position = 0) => { + if (!event || !event.id) { + logger.warn('useDashboardEvents', 'Event object is required'); + return; + } + + track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { + news_id: event.id, + news_title: event.title || '', + source: 'following_events', + position, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '📰 Following Event Clicked', { + eventId: event.id, + position, + }); + }, [track]); + + /** + * 追踪事件评论列表查看 + * @param {number} commentCount - 评论数量 + */ + const trackCommentsViewed = useCallback((commentCount = 0) => { + track('Event Comments Viewed', { + comment_count: commentCount, + is_empty: commentCount === 0, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '💬 Comments Viewed', { + commentCount, + }); + }, [track]); + + /** + * 追踪订阅信息查看 + * @param {Object} subscription - 订阅信息 + * @param {string} subscription.plan - 订阅计划 ('free' | 'pro' | 'enterprise') + * @param {string} subscription.status - 订阅状态 ('active' | 'expired' | 'cancelled') + */ + const trackSubscriptionViewed = useCallback((subscription = {}) => { + track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, { + subscription_plan: subscription.plan || 'free', + subscription_status: subscription.status || 'unknown', + is_paid_user: subscription.plan !== 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '💳 Subscription Viewed', { + plan: subscription.plan, + status: subscription.status, + }); + }, [track]); + + /** + * 追踪升级按钮点击 + * @param {string} currentPlan - 当前计划 + * @param {string} targetPlan - 目标计划 + * @param {string} source - 来源位置 + */ + const trackUpgradePlanClicked = useCallback((currentPlan = 'free', targetPlan = 'pro', source = 'dashboard') => { + track(RETENTION_EVENTS.UPGRADE_PLAN_CLICKED, { + current_plan: currentPlan, + target_plan: targetPlan, + source, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '⬆️ Upgrade Plan Clicked', { + currentPlan, + targetPlan, + source, + }); + }, [track]); + + /** + * 追踪个人资料更新 + * @param {Array} updatedFields - 更新的字段列表 + */ + const trackProfileUpdated = useCallback((updatedFields = []) => { + track(RETENTION_EVENTS.PROFILE_UPDATED, { + updated_fields: updatedFields, + field_count: updatedFields.length, + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '✏️ Profile Updated', { + updatedFields, + }); + }, [track]); + + /** + * 追踪设置更改 + * @param {string} settingName - 设置名称 + * @param {any} oldValue - 旧值 + * @param {any} newValue - 新值 + */ + const trackSettingChanged = useCallback((settingName, oldValue, newValue) => { + if (!settingName) { + logger.warn('useDashboardEvents', 'Setting name is required'); + return; + } + + track(RETENTION_EVENTS.SETTINGS_CHANGED, { + setting_name: settingName, + old_value: String(oldValue), + new_value: String(newValue), + timestamp: new Date().toISOString(), + }); + + logger.debug('useDashboardEvents', '⚙️ Setting Changed', { + settingName, + oldValue, + newValue, + }); + }, [track]); + + return { + // 功能卡片事件 + trackFunctionCardClicked, + + // 自选股相关事件 + trackWatchlistViewed, + trackWatchlistStockClicked, + trackWatchlistStockAdded, + trackWatchlistStockRemoved, + + // 关注事件相关 + trackFollowingEventsViewed, + trackFollowingEventClicked, + + // 评论相关 + trackCommentsViewed, + + // 订阅相关 + trackSubscriptionViewed, + trackUpgradePlanClicked, + + // 个人资料和设置 + trackProfileUpdated, + trackSettingChanged, + }; +}; + +export default useDashboardEvents; diff --git a/src/hooks/useNavigationEvents.js b/src/hooks/useNavigationEvents.js new file mode 100644 index 00000000..2ad99373 --- /dev/null +++ b/src/hooks/useNavigationEvents.js @@ -0,0 +1,293 @@ +// src/hooks/useNavigationEvents.js +// 导航和菜单事件追踪 Hook + +import { useCallback } from 'react'; +import { usePostHogTrack } from './usePostHogRedux'; +import { RETENTION_EVENTS } from '../lib/constants'; +import { logger } from '../utils/logger'; + +/** + * 导航事件追踪 Hook + * @param {Object} options - 配置选项 + * @param {string} options.component - 组件名称 ('top_nav' | 'sidebar' | 'breadcrumb' | 'footer') + * @returns {Object} 事件追踪处理函数集合 + */ +export const useNavigationEvents = ({ component = 'navigation' } = {}) => { + const { track } = usePostHogTrack(); + + /** + * 追踪顶部导航点击 + * @param {string} itemName - 导航项名称 + * @param {string} path - 导航目标路径 + * @param {string} category - 导航分类 ('main' | 'user' | 'utility') + */ + const trackTopNavClicked = useCallback((itemName, path = '', category = 'main') => { + if (!itemName) { + logger.warn('useNavigationEvents', 'trackTopNavClicked: itemName is required'); + return; + } + + track(RETENTION_EVENTS.TOP_NAV_CLICKED, { + item_name: itemName, + path, + category, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '🔝 Top Navigation Clicked', { + itemName, + path, + category, + }); + }, [track, component]); + + /** + * 追踪侧边栏菜单点击 + * @param {string} itemName - 菜单项名称 + * @param {string} path - 目标路径 + * @param {number} level - 菜单层级 (1=主菜单, 2=子菜单) + * @param {boolean} isExpanded - 是否展开状态 + */ + const trackSidebarMenuClicked = useCallback((itemName, path = '', level = 1, isExpanded = false) => { + if (!itemName) { + logger.warn('useNavigationEvents', 'trackSidebarMenuClicked: itemName is required'); + return; + } + + track(RETENTION_EVENTS.SIDEBAR_MENU_CLICKED, { + item_name: itemName, + path, + level, + is_expanded: isExpanded, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '📂 Sidebar Menu Clicked', { + itemName, + path, + level, + isExpanded, + }); + }, [track, component]); + + /** + * 追踪通用菜单项点击 + * @param {string} itemName - 菜单项名称 + * @param {string} menuType - 菜单类型 ('dropdown' | 'context' | 'tab') + * @param {string} path - 目标路径 + */ + const trackMenuItemClicked = useCallback((itemName, menuType = 'dropdown', path = '') => { + if (!itemName) { + logger.warn('useNavigationEvents', 'trackMenuItemClicked: itemName is required'); + return; + } + + track(RETENTION_EVENTS.MENU_ITEM_CLICKED, { + item_name: itemName, + menu_type: menuType, + path, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '📋 Menu Item Clicked', { + itemName, + menuType, + path, + }); + }, [track, component]); + + /** + * 追踪面包屑导航点击 + * @param {string} itemName - 面包屑项名称 + * @param {string} path - 目标路径 + * @param {number} position - 在面包屑中的位置 + * @param {number} totalItems - 面包屑总项数 + */ + const trackBreadcrumbClicked = useCallback((itemName, path = '', position = 0, totalItems = 0) => { + if (!itemName) { + logger.warn('useNavigationEvents', 'trackBreadcrumbClicked: itemName is required'); + return; + } + + track(RETENTION_EVENTS.BREADCRUMB_CLICKED, { + item_name: itemName, + path, + position, + total_items: totalItems, + is_last: position === totalItems - 1, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '🍞 Breadcrumb Clicked', { + itemName, + position, + totalItems, + }); + }, [track, component]); + + /** + * 追踪Logo点击(返回首页) + */ + const trackLogoClicked = useCallback(() => { + track('Logo Clicked', { + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '🏠 Logo Clicked'); + }, [track, component]); + + /** + * 追踪用户菜单展开 + * @param {Object} user - 用户对象 + * @param {number} menuItemCount - 菜单项数量 + */ + const trackUserMenuOpened = useCallback((user = {}, menuItemCount = 0) => { + track('User Menu Opened', { + user_id: user.id || null, + menu_item_count: menuItemCount, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '👤 User Menu Opened', { + userId: user.id, + menuItemCount, + }); + }, [track, component]); + + /** + * 追踪通知中心打开 + * @param {number} unreadCount - 未读通知数量 + */ + const trackNotificationCenterOpened = useCallback((unreadCount = 0) => { + track('Notification Center Opened', { + unread_count: unreadCount, + has_unread: unreadCount > 0, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '🔔 Notification Center Opened', { + unreadCount, + }); + }, [track, component]); + + /** + * 追踪语言切换 + * @param {string} fromLanguage - 原语言 + * @param {string} toLanguage - 目标语言 + */ + const trackLanguageChanged = useCallback((fromLanguage, toLanguage) => { + if (!fromLanguage || !toLanguage) { + logger.warn('useNavigationEvents', 'trackLanguageChanged: both languages are required'); + return; + } + + track('Language Changed', { + from_language: fromLanguage, + to_language: toLanguage, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '🌐 Language Changed', { + fromLanguage, + toLanguage, + }); + }, [track, component]); + + /** + * 追踪主题切换(深色/浅色模式) + * @param {string} fromTheme - 原主题 + * @param {string} toTheme - 目标主题 + */ + const trackThemeChanged = useCallback((fromTheme, toTheme) => { + if (!fromTheme || !toTheme) { + logger.warn('useNavigationEvents', 'trackThemeChanged: both themes are required'); + return; + } + + track('Theme Changed', { + from_theme: fromTheme, + to_theme: toTheme, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '🎨 Theme Changed', { + fromTheme, + toTheme, + }); + }, [track, component]); + + /** + * 追踪快捷键使用 + * @param {string} shortcut - 快捷键组合 (如 'Ctrl+K', 'Cmd+/') + * @param {string} action - 触发的动作 + */ + const trackShortcutUsed = useCallback((shortcut, action = '') => { + if (!shortcut) { + logger.warn('useNavigationEvents', 'trackShortcutUsed: shortcut is required'); + return; + } + + track('Keyboard Shortcut Used', { + shortcut, + action, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '⌨️ Keyboard Shortcut Used', { + shortcut, + action, + }); + }, [track, component]); + + /** + * 追踪返回按钮点击 + * @param {string} fromPage - 当前页面 + * @param {string} toPage - 返回到的页面 + */ + const trackBackButtonClicked = useCallback((fromPage = '', toPage = '') => { + track('Back Button Clicked', { + from_page: fromPage, + to_page: toPage, + component, + timestamp: new Date().toISOString(), + }); + + logger.debug('useNavigationEvents', '◀️ Back Button Clicked', { + fromPage, + toPage, + }); + }, [track, component]); + + return { + // 导航点击事件 + trackTopNavClicked, + trackSidebarMenuClicked, + trackMenuItemClicked, + trackBreadcrumbClicked, + trackLogoClicked, + + // 用户交互事件 + trackUserMenuOpened, + trackNotificationCenterOpened, + + // 设置变更事件 + trackLanguageChanged, + trackThemeChanged, + + // 其他交互 + trackShortcutUsed, + trackBackButtonClicked, + }; +}; + +export default useNavigationEvents; diff --git a/src/hooks/usePageTracking.js b/src/hooks/usePageTracking.js new file mode 100644 index 00000000..6a91ff6e --- /dev/null +++ b/src/hooks/usePageTracking.js @@ -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; diff --git a/src/hooks/usePostHog.js b/src/hooks/usePostHog.js new file mode 100644 index 00000000..eb51f4e9 --- /dev/null +++ b/src/hooks/usePostHog.js @@ -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; diff --git a/src/hooks/usePostHogRedux.js b/src/hooks/usePostHogRedux.js new file mode 100644 index 00000000..d2c69cd2 --- /dev/null +++ b/src/hooks/usePostHogRedux.js @@ -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 正在加载...; + * } + * + * return ( + * + * 点击追踪 + * {user && 当前用户: {user.userId}} + * + * ); + * } + * ``` + */ +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; diff --git a/src/hooks/useProfileEvents.js b/src/hooks/useProfileEvents.js new file mode 100644 index 00000000..6b580b8e --- /dev/null +++ b/src/hooks/useProfileEvents.js @@ -0,0 +1,334 @@ +// src/hooks/useProfileEvents.js +// 个人资料和设置事件追踪 Hook + +import { useCallback } from 'react'; +import { usePostHogTrack } from './usePostHogRedux'; +import { RETENTION_EVENTS } from '../lib/constants'; +import { logger } from '../utils/logger'; + +/** + * 个人资料和设置事件追踪 Hook + * @param {Object} options - 配置选项 + * @param {string} options.pageType - 页面类型 ('profile' | 'settings' | 'security') + * @returns {Object} 事件追踪处理函数集合 + */ +export const useProfileEvents = ({ pageType = 'profile' } = {}) => { + const { track } = usePostHogTrack(); + + /** + * 追踪个人资料字段编辑开始 + * @param {string} fieldName - 字段名称 ('nickname' | 'email' | 'phone' | 'avatar' | 'bio') + */ + const trackProfileFieldEditStarted = useCallback((fieldName) => { + if (!fieldName) { + logger.warn('useProfileEvents', 'trackProfileFieldEditStarted: fieldName is required'); + return; + } + + track('Profile Field Edit Started', { + field_name: fieldName, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '✏️ Profile Field Edit Started', { + fieldName, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪个人资料更新成功 + * @param {Array} updatedFields - 更新的字段列表 + * @param {Object} changes - 变更详情 + */ + const trackProfileUpdated = useCallback((updatedFields = [], changes = {}) => { + if (!updatedFields || updatedFields.length === 0) { + logger.warn('useProfileEvents', 'trackProfileUpdated: updatedFields array is required'); + return; + } + + track(RETENTION_EVENTS.PROFILE_UPDATED, { + updated_fields: updatedFields, + field_count: updatedFields.length, + changes: changes, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '✅ Profile Updated', { + updatedFields, + fieldCount: updatedFields.length, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪个人资料更新失败 + * @param {Array} attemptedFields - 尝试更新的字段 + * @param {string} errorMessage - 错误信息 + */ + const trackProfileUpdateFailed = useCallback((attemptedFields = [], errorMessage = '') => { + track('Profile Update Failed', { + attempted_fields: attemptedFields, + error_message: errorMessage, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '❌ Profile Update Failed', { + attemptedFields, + errorMessage, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪头像上传 + * @param {string} uploadMethod - 上传方式 ('file_upload' | 'url' | 'camera' | 'default_avatar') + * @param {number} fileSize - 文件大小(bytes) + */ + const trackAvatarUploaded = useCallback((uploadMethod = 'file_upload', fileSize = 0) => { + track('Avatar Uploaded', { + upload_method: uploadMethod, + file_size: fileSize, + file_size_mb: (fileSize / (1024 * 1024)).toFixed(2), + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '🖼️ Avatar Uploaded', { + uploadMethod, + fileSize, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪密码更改 + * @param {boolean} success - 是否成功 + * @param {string} errorReason - 失败原因 + */ + const trackPasswordChanged = useCallback((success = true, errorReason = '') => { + track('Password Changed', { + success, + error_reason: errorReason || null, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', success ? '🔒 Password Changed Successfully' : '❌ Password Change Failed', { + success, + errorReason, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪邮箱验证发起 + * @param {string} email - 邮箱地址 + */ + const trackEmailVerificationSent = useCallback((email = '') => { + track('Email Verification Sent', { + email_provided: Boolean(email), + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '📧 Email Verification Sent', { + emailProvided: Boolean(email), + pageType, + }); + }, [track, pageType]); + + /** + * 追踪手机号验证发起 + * @param {string} phone - 手机号 + */ + const trackPhoneVerificationSent = useCallback((phone = '') => { + track('Phone Verification Sent', { + phone_provided: Boolean(phone), + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '📱 Phone Verification Sent', { + phoneProvided: Boolean(phone), + pageType, + }); + }, [track, pageType]); + + /** + * 追踪账号绑定(微信、邮箱、手机等) + * @param {string} accountType - 账号类型 ('wechat' | 'email' | 'phone') + * @param {boolean} success - 是否成功 + */ + const trackAccountBound = useCallback((accountType, success = true) => { + if (!accountType) { + logger.warn('useProfileEvents', 'trackAccountBound: accountType is required'); + return; + } + + track('Account Bound', { + account_type: accountType, + success, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', success ? '🔗 Account Bound' : '❌ Account Bind Failed', { + accountType, + success, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪账号解绑 + * @param {string} accountType - 账号类型 + * @param {boolean} success - 是否成功 + */ + const trackAccountUnbound = useCallback((accountType, success = true) => { + if (!accountType) { + logger.warn('useProfileEvents', 'trackAccountUnbound: accountType is required'); + return; + } + + track('Account Unbound', { + account_type: accountType, + success, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', success ? '🔓 Account Unbound' : '❌ Account Unbind Failed', { + accountType, + success, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪设置项更改 + * @param {string} settingName - 设置名称 + * @param {any} oldValue - 旧值 + * @param {any} newValue - 新值 + * @param {string} category - 设置分类 ('notification' | 'privacy' | 'display' | 'advanced') + */ + const trackSettingChanged = useCallback((settingName, oldValue, newValue, category = 'general') => { + if (!settingName) { + logger.warn('useProfileEvents', 'trackSettingChanged: settingName is required'); + return; + } + + track(RETENTION_EVENTS.SETTINGS_CHANGED, { + setting_name: settingName, + old_value: String(oldValue), + new_value: String(newValue), + category, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '⚙️ Setting Changed', { + settingName, + oldValue, + newValue, + category, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪通知偏好更改 + * @param {Object} preferences - 通知偏好设置 + * @param {boolean} preferences.email - 邮件通知 + * @param {boolean} preferences.push - 推送通知 + * @param {boolean} preferences.sms - 短信通知 + */ + const trackNotificationPreferencesChanged = useCallback((preferences = {}) => { + track('Notification Preferences Changed', { + email_enabled: preferences.email || false, + push_enabled: preferences.push || false, + sms_enabled: preferences.sms || false, + total_enabled: Object.values(preferences).filter(Boolean).length, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '🔔 Notification Preferences Changed', { + preferences, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪隐私设置更改 + * @param {string} privacySetting - 隐私设置名称 + * @param {boolean} isPublic - 是否公开 + */ + const trackPrivacySettingChanged = useCallback((privacySetting, isPublic = false) => { + if (!privacySetting) { + logger.warn('useProfileEvents', 'trackPrivacySettingChanged: privacySetting is required'); + return; + } + + track('Privacy Setting Changed', { + privacy_setting: privacySetting, + is_public: isPublic, + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '🔐 Privacy Setting Changed', { + privacySetting, + isPublic, + pageType, + }); + }, [track, pageType]); + + /** + * 追踪账号删除请求 + * @param {string} reason - 删除原因 + */ + const trackAccountDeletionRequested = useCallback((reason = '') => { + track('Account Deletion Requested', { + reason, + has_reason: Boolean(reason), + page_type: pageType, + timestamp: new Date().toISOString(), + }); + + logger.debug('useProfileEvents', '🗑️ Account Deletion Requested', { + reason, + pageType, + }); + }, [track, pageType]); + + return { + // 个人资料编辑 + trackProfileFieldEditStarted, + trackProfileUpdated, + trackProfileUpdateFailed, + trackAvatarUploaded, + + // 安全和验证 + trackPasswordChanged, + trackEmailVerificationSent, + trackPhoneVerificationSent, + + // 账号绑定 + trackAccountBound, + trackAccountUnbound, + + // 设置更改 + trackSettingChanged, + trackNotificationPreferencesChanged, + trackPrivacySettingChanged, + + // 账号管理 + trackAccountDeletionRequested, + }; +}; + +export default useProfileEvents; diff --git a/src/hooks/useSearchEvents.js b/src/hooks/useSearchEvents.js new file mode 100644 index 00000000..bd120042 --- /dev/null +++ b/src/hooks/useSearchEvents.js @@ -0,0 +1,244 @@ +// src/hooks/useSearchEvents.js +// 全局搜索功能事件追踪 Hook + +import { useCallback } from 'react'; +import { usePostHogTrack } from './usePostHogRedux'; +import { RETENTION_EVENTS } from '../lib/constants'; +import { logger } from '../utils/logger'; + +/** + * 全局搜索事件追踪 Hook + * @param {Object} options - 配置选项 + * @param {string} options.context - 搜索上下文 ('global' | 'stock' | 'news' | 'concept' | 'simulation') + * @returns {Object} 事件追踪处理函数集合 + */ +export const useSearchEvents = ({ context = 'global' } = {}) => { + const { track } = usePostHogTrack(); + + /** + * 追踪搜索开始(聚焦搜索框) + * @param {string} placeholder - 搜索框提示文本 + */ + const trackSearchInitiated = useCallback((placeholder = '') => { + track(RETENTION_EVENTS.SEARCH_INITIATED, { + context, + placeholder, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSearchEvents', '🔍 Search Initiated', { + context, + placeholder, + }); + }, [track, context]); + + /** + * 追踪搜索查询提交 + * @param {string} query - 搜索查询词 + * @param {number} resultCount - 搜索结果数量 + * @param {Object} filters - 应用的筛选条件 + */ + const trackSearchQuerySubmitted = useCallback((query, resultCount = 0, filters = {}) => { + if (!query) { + logger.warn('useSearchEvents', 'trackSearchQuerySubmitted: query is required'); + return; + } + + track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, { + query, + query_length: query.length, + result_count: resultCount, + has_results: resultCount > 0, + context, + filters: filters, + filter_count: Object.keys(filters).length, + timestamp: new Date().toISOString(), + }); + + // 如果没有搜索结果,额外追踪 + if (resultCount === 0) { + track(RETENTION_EVENTS.SEARCH_NO_RESULTS, { + query, + context, + filters, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSearchEvents', '❌ Search No Results', { + query, + context, + }); + } else { + logger.debug('useSearchEvents', '✅ Search Query Submitted', { + query, + resultCount, + context, + }); + } + }, [track, context]); + + /** + * 追踪搜索结果点击 + * @param {Object} result - 被点击的搜索结果 + * @param {string} result.type - 结果类型 ('stock' | 'news' | 'concept' | 'event') + * @param {string} result.id - 结果ID + * @param {string} result.title - 结果标题 + * @param {number} position - 在搜索结果中的位置 + * @param {string} query - 搜索查询词 + */ + const trackSearchResultClicked = useCallback((result, position = 0, query = '') => { + if (!result || !result.type) { + logger.warn('useSearchEvents', 'trackSearchResultClicked: result object with type is required'); + return; + } + + track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, { + result_type: result.type, + result_id: result.id || result.code || '', + result_title: result.title || result.name || '', + position, + query, + context, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSearchEvents', '🎯 Search Result Clicked', { + type: result.type, + id: result.id || result.code, + position, + context, + }); + }, [track, context]); + + /** + * 追踪搜索筛选应用 + * @param {Object} filters - 应用的筛选条件 + * @param {string} filterType - 筛选类型 ('sort' | 'category' | 'date_range' | 'price_range') + * @param {any} filterValue - 筛选值 + */ + const trackSearchFilterApplied = useCallback((filterType, filterValue, filters = {}) => { + if (!filterType) { + logger.warn('useSearchEvents', 'trackSearchFilterApplied: filterType is required'); + return; + } + + track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, { + filter_type: filterType, + filter_value: String(filterValue), + all_filters: filters, + context, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSearchEvents', '🔍 Search Filter Applied', { + filterType, + filterValue, + context, + }); + }, [track, context]); + + /** + * 追踪搜索建议点击(自动完成) + * @param {string} suggestion - 被点击的搜索建议 + * @param {number} position - 在建议列表中的位置 + * @param {string} source - 建议来源 ('history' | 'popular' | 'related') + */ + const trackSearchSuggestionClicked = useCallback((suggestion, position = 0, source = 'popular') => { + if (!suggestion) { + logger.warn('useSearchEvents', 'trackSearchSuggestionClicked: suggestion is required'); + return; + } + + track('Search Suggestion Clicked', { + suggestion, + position, + source, + context, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSearchEvents', '💡 Search Suggestion Clicked', { + suggestion, + position, + source, + context, + }); + }, [track, context]); + + /** + * 追踪搜索历史查看 + * @param {number} historyCount - 历史记录数量 + */ + const trackSearchHistoryViewed = useCallback((historyCount = 0) => { + track('Search History Viewed', { + history_count: historyCount, + has_history: historyCount > 0, + context, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSearchEvents', '📜 Search History Viewed', { + historyCount, + context, + }); + }, [track, context]); + + /** + * 追踪搜索历史清除 + */ + const trackSearchHistoryCleared = useCallback(() => { + track('Search History Cleared', { + context, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSearchEvents', '🗑️ Search History Cleared', { + context, + }); + }, [track, context]); + + /** + * 追踪热门搜索词点击 + * @param {string} keyword - 被点击的热门关键词 + * @param {number} position - 在列表中的位置 + * @param {number} heatScore - 热度分数 + */ + const trackPopularKeywordClicked = useCallback((keyword, position = 0, heatScore = 0) => { + if (!keyword) { + logger.warn('useSearchEvents', 'trackPopularKeywordClicked: keyword is required'); + return; + } + + track('Popular Keyword Clicked', { + keyword, + position, + heat_score: heatScore, + context, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSearchEvents', '🔥 Popular Keyword Clicked', { + keyword, + position, + context, + }); + }, [track, context]); + + return { + // 搜索流程事件 + trackSearchInitiated, + trackSearchQuerySubmitted, + trackSearchResultClicked, + + // 筛选和建议 + trackSearchFilterApplied, + trackSearchSuggestionClicked, + + // 历史和热门 + trackSearchHistoryViewed, + trackSearchHistoryCleared, + trackPopularKeywordClicked, + }; +}; + +export default useSearchEvents; diff --git a/src/hooks/useSubscriptionEvents.js b/src/hooks/useSubscriptionEvents.js new file mode 100644 index 00000000..c6fdfe3f --- /dev/null +++ b/src/hooks/useSubscriptionEvents.js @@ -0,0 +1,394 @@ +// src/hooks/useSubscriptionEvents.js +// 订阅和支付事件追踪 Hook + +import { useCallback } from 'react'; +import { usePostHogTrack } from './usePostHogRedux'; +import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants'; +import { logger } from '../utils/logger'; + +/** + * 订阅和支付事件追踪 Hook + * @param {Object} options - 配置选项 + * @param {Object} options.currentSubscription - 当前订阅信息 + * @returns {Object} 事件追踪处理函数集合 + */ +export const useSubscriptionEvents = ({ currentSubscription = null } = {}) => { + const { track } = usePostHogTrack(); + + /** + * 追踪付费墙展示 + * @param {string} feature - 被限制的功能名称 + * @param {string} requiredPlan - 需要的订阅计划 + * @param {string} triggerLocation - 触发位置 + */ + const trackPaywallShown = useCallback((feature, requiredPlan = 'pro', triggerLocation = '') => { + if (!feature) { + logger.warn('useSubscriptionEvents', 'trackPaywallShown: feature is required'); + return; + } + + track(REVENUE_EVENTS.PAYWALL_SHOWN, { + feature, + required_plan: requiredPlan, + current_plan: currentSubscription?.plan || 'free', + trigger_location: triggerLocation, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '🚧 Paywall Shown', { + feature, + requiredPlan, + triggerLocation, + }); + }, [track, currentSubscription]); + + /** + * 追踪付费墙关闭 + * @param {string} feature - 功能名称 + * @param {string} closeMethod - 关闭方式 ('dismiss' | 'upgrade_clicked' | 'back_button') + */ + const trackPaywallDismissed = useCallback((feature, closeMethod = 'dismiss') => { + if (!feature) { + logger.warn('useSubscriptionEvents', 'trackPaywallDismissed: feature is required'); + return; + } + + track(REVENUE_EVENTS.PAYWALL_DISMISSED, { + feature, + close_method: closeMethod, + current_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '❌ Paywall Dismissed', { + feature, + closeMethod, + }); + }, [track, currentSubscription]); + + /** + * 追踪升级按钮点击 + * @param {string} targetPlan - 目标订阅计划 + * @param {string} source - 来源位置 + * @param {string} feature - 关联的功能(如果从付费墙点击) + */ + const trackUpgradePlanClicked = useCallback((targetPlan = 'pro', source = '', feature = '') => { + track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, { + current_plan: currentSubscription?.plan || 'free', + target_plan: targetPlan, + source, + feature: feature || null, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '⬆️ Upgrade Plan Clicked', { + currentPlan: currentSubscription?.plan, + targetPlan, + source, + feature, + }); + }, [track, currentSubscription]); + + /** + * 追踪订阅页面查看 + * @param {string} source - 来源 + */ + const trackSubscriptionPageViewed = useCallback((source = '') => { + track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, { + current_plan: currentSubscription?.plan || 'free', + subscription_status: currentSubscription?.status || 'unknown', + is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free', + source, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '💳 Subscription Page Viewed', { + currentPlan: currentSubscription?.plan, + source, + }); + }, [track, currentSubscription]); + + /** + * 追踪定价计划查看 + * @param {string} planName - 计划名称 ('free' | 'pro' | 'enterprise') + * @param {number} price - 价格 + */ + const trackPricingPlanViewed = useCallback((planName, price = 0) => { + if (!planName) { + logger.warn('useSubscriptionEvents', 'trackPricingPlanViewed: planName is required'); + return; + } + + track('Pricing Plan Viewed', { + plan_name: planName, + price, + current_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '👀 Pricing Plan Viewed', { + planName, + price, + }); + }, [track, currentSubscription]); + + /** + * 追踪定价计划选择 + * @param {string} planName - 选择的计划名称 + * @param {string} billingCycle - 计费周期 ('monthly' | 'yearly') + * @param {number} price - 价格 + */ + const trackPricingPlanSelected = useCallback((planName, billingCycle = 'monthly', price = 0) => { + if (!planName) { + logger.warn('useSubscriptionEvents', 'trackPricingPlanSelected: planName is required'); + return; + } + + track('Pricing Plan Selected', { + plan_name: planName, + billing_cycle: billingCycle, + price, + current_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '✅ Pricing Plan Selected', { + planName, + billingCycle, + price, + }); + }, [track, currentSubscription]); + + /** + * 追踪支付页面查看 + * @param {string} planName - 购买的计划 + * @param {number} amount - 支付金额 + */ + const trackPaymentPageViewed = useCallback((planName, amount = 0) => { + track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, { + plan_name: planName, + amount, + current_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '💰 Payment Page Viewed', { + planName, + amount, + }); + }, [track, currentSubscription]); + + /** + * 追踪支付方式选择 + * @param {string} paymentMethod - 支付方式 ('wechat_pay' | 'alipay' | 'credit_card') + * @param {number} amount - 支付金额 + */ + const trackPaymentMethodSelected = useCallback((paymentMethod, amount = 0) => { + if (!paymentMethod) { + logger.warn('useSubscriptionEvents', 'trackPaymentMethodSelected: paymentMethod is required'); + return; + } + + track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, { + payment_method: paymentMethod, + amount, + current_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '💳 Payment Method Selected', { + paymentMethod, + amount, + }); + }, [track, currentSubscription]); + + /** + * 追踪支付发起 + * @param {Object} paymentInfo - 支付信息 + * @param {string} paymentInfo.planName - 计划名称 + * @param {string} paymentInfo.paymentMethod - 支付方式 + * @param {number} paymentInfo.amount - 金额 + * @param {string} paymentInfo.billingCycle - 计费周期 + * @param {string} paymentInfo.orderId - 订单ID + */ + const trackPaymentInitiated = useCallback((paymentInfo = {}) => { + track(REVENUE_EVENTS.PAYMENT_INITIATED, { + plan_name: paymentInfo.planName, + payment_method: paymentInfo.paymentMethod, + amount: paymentInfo.amount, + billing_cycle: paymentInfo.billingCycle, + order_id: paymentInfo.orderId, + current_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '🚀 Payment Initiated', { + planName: paymentInfo.planName, + amount: paymentInfo.amount, + paymentMethod: paymentInfo.paymentMethod, + }); + }, [track, currentSubscription]); + + /** + * 追踪支付成功 + * @param {Object} paymentInfo - 支付信息 + */ + const trackPaymentSuccessful = useCallback((paymentInfo = {}) => { + track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, { + plan_name: paymentInfo.planName, + payment_method: paymentInfo.paymentMethod, + amount: paymentInfo.amount, + billing_cycle: paymentInfo.billingCycle, + order_id: paymentInfo.orderId, + transaction_id: paymentInfo.transactionId, + previous_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '✅ Payment Successful', { + planName: paymentInfo.planName, + amount: paymentInfo.amount, + orderId: paymentInfo.orderId, + }); + }, [track, currentSubscription]); + + /** + * 追踪支付失败 + * @param {Object} paymentInfo - 支付信息 + * @param {string} errorReason - 失败原因 + */ + const trackPaymentFailed = useCallback((paymentInfo = {}, errorReason = '') => { + track(REVENUE_EVENTS.PAYMENT_FAILED, { + plan_name: paymentInfo.planName, + payment_method: paymentInfo.paymentMethod, + amount: paymentInfo.amount, + error_reason: errorReason, + order_id: paymentInfo.orderId, + current_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '❌ Payment Failed', { + planName: paymentInfo.planName, + errorReason, + orderId: paymentInfo.orderId, + }); + }, [track, currentSubscription]); + + /** + * 追踪订阅创建成功 + * @param {Object} subscription - 订阅信息 + */ + const trackSubscriptionCreated = useCallback((subscription = {}) => { + track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, { + plan_name: subscription.plan, + billing_cycle: subscription.billingCycle, + amount: subscription.amount, + start_date: subscription.startDate, + end_date: subscription.endDate, + previous_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '🎉 Subscription Created', { + plan: subscription.plan, + billingCycle: subscription.billingCycle, + }); + }, [track, currentSubscription]); + + /** + * 追踪订阅续费 + * @param {Object} subscription - 订阅信息 + */ + const trackSubscriptionRenewed = useCallback((subscription = {}) => { + track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, { + plan_name: subscription.plan, + amount: subscription.amount, + previous_end_date: subscription.previousEndDate, + new_end_date: subscription.newEndDate, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '🔄 Subscription Renewed', { + plan: subscription.plan, + amount: subscription.amount, + }); + }, [track]); + + /** + * 追踪订阅取消 + * @param {string} reason - 取消原因 + * @param {boolean} cancelImmediately - 是否立即取消 + */ + const trackSubscriptionCancelled = useCallback((reason = '', cancelImmediately = false) => { + track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, { + plan_name: currentSubscription?.plan, + reason, + has_reason: Boolean(reason), + cancel_immediately: cancelImmediately, + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', '🚫 Subscription Cancelled', { + plan: currentSubscription?.plan, + reason, + cancelImmediately, + }); + }, [track, currentSubscription]); + + /** + * 追踪优惠券应用 + * @param {string} couponCode - 优惠券代码 + * @param {number} discountAmount - 折扣金额 + * @param {boolean} success - 是否成功 + */ + const trackCouponApplied = useCallback((couponCode, discountAmount = 0, success = true) => { + if (!couponCode) { + logger.warn('useSubscriptionEvents', 'trackCouponApplied: couponCode is required'); + return; + } + + track('Coupon Applied', { + coupon_code: couponCode, + discount_amount: discountAmount, + success, + current_plan: currentSubscription?.plan || 'free', + timestamp: new Date().toISOString(), + }); + + logger.debug('useSubscriptionEvents', success ? '🎟️ Coupon Applied' : '❌ Coupon Failed', { + couponCode, + discountAmount, + success, + }); + }, [track, currentSubscription]); + + return { + // 付费墙事件 + trackPaywallShown, + trackPaywallDismissed, + trackUpgradePlanClicked, + + // 订阅页面事件 + trackSubscriptionPageViewed, + trackPricingPlanViewed, + trackPricingPlanSelected, + + // 支付流程事件 + trackPaymentPageViewed, + trackPaymentMethodSelected, + trackPaymentInitiated, + trackPaymentSuccessful, + trackPaymentFailed, + + // 订阅管理事件 + trackSubscriptionCreated, + trackSubscriptionRenewed, + trackSubscriptionCancelled, + + // 优惠券事件 + trackCouponApplied, + }; +}; + +export default useSubscriptionEvents; diff --git a/src/layouts/AppFooter.js b/src/layouts/AppFooter.js index e799ae2b..795bbe59 100644 --- a/src/layouts/AppFooter.js +++ b/src/layouts/AppFooter.js @@ -1,5 +1,6 @@ import React from 'react'; import { Box, Container, VStack, HStack, Text, Link, useColorModeValue } from '@chakra-ui/react'; +import RiskDisclaimer from '../components/RiskDisclaimer'; /** * 应用通用页脚组件 @@ -10,6 +11,7 @@ const AppFooter = () => { + © 2024 价值前沿. 保留所有权利. diff --git a/src/lib/constants.js b/src/lib/constants.js new file mode 100644 index 00000000..a5886a8b --- /dev/null +++ b/src/lib/constants.js @@ -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); +}; diff --git a/src/lib/posthog.js b/src/lib/posthog.js new file mode 100644 index 00000000..1173a250 --- /dev/null +++ b/src/lib/posthog.js @@ -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; diff --git a/src/mocks/browser.js b/src/mocks/browser.js index ebdb6f96..8a578394 100644 --- a/src/mocks/browser.js +++ b/src/mocks/browser.js @@ -19,7 +19,10 @@ export async function startMockServiceWorker() { try { await worker.start({ - // 不显示未拦截的请求警告(可选) + // 🎯 智能穿透模式(关键配置) + // 'bypass': 未定义 Mock 的请求自动转发到真实后端 + // 'warn': 未定义的请求会显示警告(调试用) + // 'error': 未定义的请求会抛出错误(严格模式) onUnhandledRequest: 'bypass', // 自定义 Service Worker URL(如果需要) @@ -27,7 +30,7 @@ export async function startMockServiceWorker() { url: '/mockServiceWorker.js', }, - // 静默模式(不在控制台打印启动消息) + // 是否在控制台显示启动日志和拦截日志 静默模式(不在控制台打印启动消息) quiet: false, }); @@ -36,11 +39,11 @@ export async function startMockServiceWorker() { 'color: #4CAF50; font-weight: bold; font-size: 14px;' ); console.log( - '%c提示: 所有 API 请求将使用本地 Mock 数据', + '%c智能穿透模式:已定义 Mock → 返回假数据 | 未定义 Mock → 转发到 ' + (process.env.REACT_APP_API_URL || '真实后端'), 'color: #FF9800; font-size: 12px;' ); console.log( - '%c要禁用 Mock,请设置 REACT_APP_ENABLE_MOCK=false', + '%c查看 src/mocks/handlers/ 目录管理 Mock 接口', 'color: #2196F3; font-size: 12px;' ); } catch (error) { diff --git a/src/mocks/handlers/concept.js b/src/mocks/handlers/concept.js index 310449f6..6d99371c 100644 --- a/src/mocks/handlers/concept.js +++ b/src/mocks/handlers/concept.js @@ -155,5 +155,222 @@ export const conceptHandlers = [ total: stocks.length, concept_id: conceptId }); + }), + + // 获取最新交易日期 + http.get('http://111.198.58.126:16801/price/latest', async () => { + await delay(200); + + const today = new Date(); + const dateStr = today.toISOString().split('T')[0].replace(/-/g, ''); + + console.log('[Mock Concept] 获取最新交易日期:', dateStr); + + return HttpResponse.json({ + latest_date: dateStr, + timestamp: today.toISOString() + }); + }), + + // 搜索概念(硬编码 URL) + http.post('http://111.198.58.126:16801/search', async ({ request }) => { + await delay(300); + + try { + const body = await request.json(); + const { query = '', size = 20, page = 1, sort_by = 'change_pct' } = body; + + console.log('[Mock Concept] 搜索概念 (硬编码URL):', { query, size, page, sort_by }); + + let results = generatePopularConcepts(size); + + if (query) { + results = results.filter(item => + item.concept.toLowerCase().includes(query.toLowerCase()) + ); + } + + if (sort_by === 'change_pct') { + results.sort((a, b) => b.price_info.avg_change_pct - a.price_info.avg_change_pct); + } else if (sort_by === 'stock_count') { + results.sort((a, b) => b.stock_count - a.stock_count); + } else if (sort_by === 'hot_score') { + results.sort((a, b) => b.hot_score - a.hot_score); + } + + return HttpResponse.json({ + results, + total: results.length, + page, + size, + message: '搜索成功' + }); + } catch (error) { + console.error('[Mock Concept] 搜索失败:', error); + return HttpResponse.json( + { results: [], total: 0, error: '搜索失败' }, + { status: 500 } + ); + } + }), + + // 获取统计数据 + http.get('http://111.198.58.126:16801/statistics', async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const minStockCount = parseInt(url.searchParams.get('min_stock_count') || '3'); + const days = parseInt(url.searchParams.get('days') || '7'); + + console.log('[Mock Concept] 获取统计数据:', { minStockCount, days }); + + return HttpResponse.json({ + total_concepts: 150, + active_concepts: 120, + avg_stock_count: 25, + top_concepts: generatePopularConcepts(10), + min_stock_count: minStockCount, + days: days, + updated_at: new Date().toISOString() + }); + }), + + // 获取概念价格时间序列 + http.get('http://111.198.58.126:16801/concept/:conceptId/price-timeseries', async ({ params, request }) => { + await delay(300); + + const { conceptId } = params; + const url = new URL(request.url); + const startDate = url.searchParams.get('start_date'); + const endDate = url.searchParams.get('end_date'); + + console.log('[Mock Concept] 获取价格时间序列:', { conceptId, startDate, endDate }); + + // 生成时间序列数据 + const timeseries = []; + const start = new Date(startDate || '2024-01-01'); + const end = new Date(endDate || new Date()); + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)); + + for (let i = 0; i <= daysDiff; i++) { + const date = new Date(start); + date.setDate(date.getDate() + i); + + // 跳过周末 + if (date.getDay() !== 0 && date.getDay() !== 6) { + timeseries.push({ + trade_date: date.toISOString().split('T')[0], // 改为 trade_date + avg_change_pct: parseFloat((Math.random() * 8 - 2).toFixed(2)), // 转为数值 + stock_count: Math.floor(Math.random() * 30) + 10, + volume: Math.floor(Math.random() * 1000000000) + }); + } + } + + return HttpResponse.json({ + concept_id: conceptId, + timeseries: timeseries, + start_date: startDate, + end_date: endDate + }); + }), + + // 获取概念相关新闻 (search_china_news) + http.get('http://111.198.58.126:21891/search_china_news', async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const query = url.searchParams.get('query'); + const exactMatch = url.searchParams.get('exact_match'); + const startDate = url.searchParams.get('start_date'); + const endDate = url.searchParams.get('end_date'); + const topK = parseInt(url.searchParams.get('top_k') || '100'); + + console.log('[Mock Concept] 搜索中国新闻:', { query, exactMatch, startDate, endDate, topK }); + + // 生成新闻数据 + const news = []; + const newsCount = Math.min(topK, Math.floor(Math.random() * 15) + 5); // 5-20 条新闻 + + for (let i = 0; i < newsCount; i++) { + const daysAgo = Math.floor(Math.random() * 100); // 0-100 天前 + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + + const hour = Math.floor(Math.random() * 24); + const minute = Math.floor(Math.random() * 60); + const publishedTime = `${date.toISOString().split('T')[0]} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`; + + news.push({ + id: `news_${i}`, + title: `${query || '概念'}板块动态:${['利好政策发布', '行业景气度提升', '龙头企业业绩超预期', '技术突破进展', '市场需求旺盛'][i % 5]}`, + detail: `${query || '概念'}相关新闻详细内容。近期${query || '概念'}板块表现活跃,市场关注度持续上升。多家券商研报指出,${query || '概念'}行业前景广阔,建议重点关注龙头企业投资机会。`, + description: `${query || '概念'}板块最新动态摘要...`, + source: ['新浪财经', '东方财富网', '财联社', '证券时报', '中国证券报', '上海证券报'][Math.floor(Math.random() * 6)], + published_time: publishedTime, + url: `https://finance.sina.com.cn/stock/news/${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}/news_${i}.html` + }); + } + + // 按时间降序排序 + news.sort((a, b) => new Date(b.published_time) - new Date(a.published_time)); + + // 返回数组(不是对象) + return HttpResponse.json(news); + }), + + // 获取概念相关研报 (search) + http.get('http://111.198.58.126:8811/search', async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const query = url.searchParams.get('query'); + const mode = url.searchParams.get('mode'); + const exactMatch = url.searchParams.get('exact_match'); + const size = parseInt(url.searchParams.get('size') || '30'); + const startDate = url.searchParams.get('start_date'); + + console.log('[Mock Concept] 搜索研报:', { query, mode, exactMatch, size, startDate }); + + // 生成研报数据 + const reports = []; + const reportCount = Math.min(size, Math.floor(Math.random() * 10) + 3); // 3-12 份研报 + + const publishers = ['中信证券', '国泰君安', '华泰证券', '招商证券', '海通证券', '广发证券', '申万宏源', '兴业证券']; + const authors = ['张明', '李华', '王强', '刘洋', '陈杰', '赵敏']; + const ratings = ['买入', '增持', '中性', '减持', '强烈推荐']; + const securityNames = ['行业研究', '公司研究', '策略研究', '宏观研究', '固收研究']; + + for (let i = 0; i < reportCount; i++) { + const daysAgo = Math.floor(Math.random() * 100); // 0-100 天前 + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + + const declareDate = `${date.toISOString().split('T')[0]} ${String(Math.floor(Math.random() * 24)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}:00`; + + reports.push({ + id: `report_${i}`, + report_title: `${query || '概念'}行业${['深度研究报告', '投资策略分析', '行业景气度跟踪', '估值分析报告', '竞争格局研究'][i % 5]}`, + content: `${query || '概念'}行业研究报告内容摘要。\n\n核心观点:\n1. ${query || '概念'}行业景气度持续向好,市场规模预计将保持高速增长。\n2. 龙头企业凭借技术优势和规模效应,市场份额有望进一步提升。\n3. 政策支持力度加大,为行业发展提供有力保障。\n\n投资建议:建议重点关注行业龙头企业,给予"${ratings[Math.floor(Math.random() * ratings.length)]}"评级。`, + abstract: `本报告深入分析了${query || '概念'}行业的发展趋势、竞争格局和投资机会,认为行业具备良好的成长性...`, + publisher: publishers[Math.floor(Math.random() * publishers.length)], + author: authors[Math.floor(Math.random() * authors.length)], + declare_date: declareDate, + rating: ratings[Math.floor(Math.random() * ratings.length)], + security_name: securityNames[Math.floor(Math.random() * securityNames.length)], + content_url: `https://pdf.dfcfw.com/pdf/H3_${1000000 + i}_1_${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}.pdf` + }); + } + + // 按时间降序排序 + reports.sort((a, b) => new Date(b.declare_date) - new Date(a.declare_date)); + + // 返回符合组件期望的格式 + return HttpResponse.json({ + results: reports, + total: reports.length, + query: query, + mode: mode + }); }) ]; diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index 3869ff4d..81247fa0 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -923,4 +923,157 @@ export const eventHandlers = [ } }); }), + + // ==================== 历史事件对比相关 ==================== + + // 获取历史事件列表 + http.get('/api/events/:eventId/historical', async ({ params }) => { + await delay(400); + + const { eventId } = params; + + console.log('[Mock] 获取历史事件列表, eventId:', eventId); + + // 生成历史事件数据 + const generateHistoricalEvents = (count = 5) => { + const events = []; + const eventTitles = [ + '芯片产业链政策扶持升级', + '新能源汽车销量创历史新高', + '人工智能大模型技术突破', + '半导体设备国产化加速', + '数字经济政策利好发布', + '新能源产业链整合提速', + '医药创新药获批上市', + '5G应用场景扩展', + '智能驾驶技术迭代升级', + '储能行业景气度上行' + ]; + + const importanceLevels = [1, 2, 3, 4, 5]; + + for (let i = 0; i < count; i++) { + const daysAgo = Math.floor(Math.random() * 180) + 30; // 30-210 天前 + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + + const importance = importanceLevels[Math.floor(Math.random() * importanceLevels.length)]; + + events.push({ + id: `hist_event_${i + 1}`, + title: eventTitles[i % eventTitles.length], + description: `${eventTitles[i % eventTitles.length]}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`, + date: date.toISOString().split('T')[0], + importance: importance, + similarity: parseFloat((Math.random() * 0.3 + 0.7).toFixed(2)), // 0.7-1.0 + impact_sectors: [ + ['半导体', '芯片设计', 'EDA'], + ['新能源汽车', '锂电池', '充电桩'], + ['人工智能', '算力', '大模型'], + ['半导体设备', '国产替代', '集成电路'], + ['数字经济', '云计算', '大数据'] + ][i % 5], + affected_stocks_count: Math.floor(Math.random() * 30) + 10, // 10-40 只股票 + avg_change_pct: parseFloat((Math.random() * 10 - 2).toFixed(2)), // -2% to +8% + created_at: date.toISOString() + }); + } + + // 按日期降序排序 + return events.sort((a, b) => new Date(b.date) - new Date(a.date)); + }; + + try { + const historicalEvents = generateHistoricalEvents(5); + + return HttpResponse.json({ + success: true, + data: historicalEvents, + total: historicalEvents.length, + message: '获取历史事件列表成功' + }); + } catch (error) { + console.error('[Mock] 获取历史事件列表失败:', error); + return HttpResponse.json( + { + success: false, + error: '获取历史事件列表失败', + data: [] + }, + { status: 500 } + ); + } + }), + + // 获取历史事件相关股票 + http.get('/api/historical-events/:eventId/stocks', async ({ params }) => { + await delay(500); + + const { eventId } = params; + + console.log('[Mock] 获取历史事件相关股票, eventId:', eventId); + + // 生成历史事件相关股票数据 + const generateHistoricalEventStocks = (count = 10) => { + const stocks = []; + const sectors = ['半导体', '新能源', '医药', '消费电子', '人工智能', '5G通信']; + const stockNames = [ + '中芯国际', '长江存储', '华为海思', '紫光国微', '兆易创新', + '宁德时代', '比亚迪', '隆基绿能', '阳光电源', '亿纬锂能', + '恒瑞医药', '迈瑞医疗', '药明康德', '泰格医药', '康龙化成', + '立讯精密', '歌尔声学', '京东方A', 'TCL科技', '海康威视', + '科大讯飞', '商汤科技', '寒武纪', '海光信息', '中兴通讯' + ]; + + for (let i = 0; i < count; i++) { + const stockCode = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`; + const changePct = (Math.random() * 15 - 3).toFixed(2); // -3% ~ +12% + const correlation = (Math.random() * 0.4 + 0.6).toFixed(2); // 0.6 ~ 1.0 + + stocks.push({ + id: `stock_${i}`, + stock_code: `${stockCode}.${Math.random() > 0.5 ? 'SH' : 'SZ'}`, + stock_name: stockNames[i % stockNames.length], + sector: sectors[Math.floor(Math.random() * sectors.length)], + correlation: parseFloat(correlation), + event_day_change_pct: parseFloat(changePct), + relation_desc: { + data: [ + { + query_part: `该公司是${sectors[Math.floor(Math.random() * sectors.length)]}行业龙头,受事件影响显著,市场关注度高,订单量同比增长${Math.floor(Math.random() * 50 + 20)}%`, + sentences: `根据行业研究报告,该公司在${sectors[Math.floor(Math.random() * sectors.length)]}领域具有核心技术优势,产能利用率达到${Math.floor(Math.random() * 20 + 80)}%,随着事件的深入发展,公司业绩有望持续受益。机构预测未来三年复合增长率将达到${Math.floor(Math.random() * 30 + 15)}%以上`, + match_score: correlation > 0.8 ? '好' : (correlation > 0.6 ? '中' : '一般'), + author: ['中信证券', '国泰君安', '华泰证券', '招商证券'][Math.floor(Math.random() * 4)], + declare_date: new Date(Date.now() - Math.floor(Math.random() * 90) * 24 * 60 * 60 * 1000).toISOString(), + report_title: `${stockNames[i % stockNames.length]}深度研究报告` + } + ] + } + }); + } + + // 按相关度降序排序 + return stocks.sort((a, b) => b.correlation - a.correlation); + }; + + try { + const stocks = generateHistoricalEventStocks(15); + + return HttpResponse.json({ + success: true, + data: stocks, + message: '获取历史事件相关股票成功' + }); + } catch (error) { + console.error('[Mock] 获取历史事件相关股票失败:', error); + return HttpResponse.json( + { + success: false, + error: '获取历史事件相关股票失败', + data: [] + }, + { status: 500 } + ); + } + }), ]; diff --git a/src/mocks/handlers/index.js b/src/mocks/handlers/index.js index bce489c7..233bc60e 100644 --- a/src/mocks/handlers/index.js +++ b/src/mocks/handlers/index.js @@ -12,6 +12,7 @@ import { stockHandlers } from './stock'; import { companyHandlers } from './company'; import { marketHandlers } from './market'; import { financialHandlers } from './financial'; +import { limitAnalyseHandlers } from './limitAnalyse'; // 可以在这里添加更多的 handlers // import { userHandlers } from './user'; @@ -28,5 +29,6 @@ export const handlers = [ ...companyHandlers, ...marketHandlers, ...financialHandlers, + ...limitAnalyseHandlers, // ...userHandlers, ]; diff --git a/src/mocks/handlers/limitAnalyse.js b/src/mocks/handlers/limitAnalyse.js new file mode 100644 index 00000000..a9d24384 --- /dev/null +++ b/src/mocks/handlers/limitAnalyse.js @@ -0,0 +1,344 @@ +// src/mocks/handlers/limitAnalyse.js +// 涨停分析相关的 Mock Handlers + +import { http, HttpResponse } from 'msw'; + +// 模拟延迟 +const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// 生成可用日期列表(最近30个交易日) +const generateAvailableDates = () => { + const dates = []; + const today = new Date(); + let count = 0; + + for (let i = 0; i < 60 && count < 30; i++) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dayOfWeek = date.getDay(); + + // 跳过周末 + if (dayOfWeek !== 0 && dayOfWeek !== 6) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const dateStr = `${year}${month}${day}`; + + // 返回包含 date 和 count 字段的对象 + dates.push({ + date: dateStr, + count: Math.floor(Math.random() * 80) + 30 // 30-110 只涨停股票 + }); + count++; + } + } + + return dates; +}; + +// 生成板块数据 +const generateSectors = (count = 8) => { + const sectorNames = [ + '人工智能', 'ChatGPT', '数字经济', + '新能源汽车', '光伏', '锂电池', + '半导体', '芯片', '5G通信', + '医疗器械', '创新药', '中药', + '白酒', '食品饮料', '消费电子', + '军工', '航空航天', '新材料' + ]; + + const sectors = []; + for (let i = 0; i < Math.min(count, sectorNames.length); i++) { + const stockCount = Math.floor(Math.random() * 15) + 5; + const stocks = []; + + for (let j = 0; j < stockCount; j++) { + stocks.push({ + code: `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`, + name: `${sectorNames[i]}股票${j + 1}`, + latest_limit_time: `${Math.floor(Math.random() * 4) + 9}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`, + limit_up_count: Math.floor(Math.random() * 3) + 1, + price: (Math.random() * 100 + 10).toFixed(2), + change_pct: (Math.random() * 5 + 5).toFixed(2), + turnover_rate: (Math.random() * 30 + 5).toFixed(2), + volume: Math.floor(Math.random() * 100000000 + 10000000), + amount: (Math.random() * 1000000000 + 100000000).toFixed(2), + limit_type: Math.random() > 0.7 ? '一字板' : (Math.random() > 0.5 ? 'T字板' : '普通涨停'), + 封单金额: (Math.random() * 500000000).toFixed(2), + }); + } + + sectors.push({ + sector_name: sectorNames[i], + stock_count: stockCount, + avg_limit_time: `${Math.floor(Math.random() * 2) + 10}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`, + stocks: stocks, + }); + } + + return sectors; +}; + +// 生成高位股数据(用于 HighPositionStocks 组件) +const generateHighPositionStocks = () => { + const stocks = []; + const stockNames = [ + '宁德时代', '比亚迪', '隆基绿能', '东方财富', '中际旭创', + '京东方A', '海康威视', '立讯精密', '三一重工', '恒瑞医药', + '三六零', '东方通信', '贵州茅台', '五粮液', '中国平安' + ]; + const industries = [ + '锂电池', '新能源汽车', '光伏', '金融科技', '通信设备', + '显示器件', '安防设备', '电子元件', '工程机械', '医药制造', + '网络安全', '通信服务', '白酒', '食品饮料', '保险' + ]; + + for (let i = 0; i < stockNames.length; i++) { + const code = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`; + const continuousDays = Math.floor(Math.random() * 8) + 2; // 2-9连板 + const price = parseFloat((Math.random() * 100 + 20).toFixed(2)); + const increaseRate = parseFloat((Math.random() * 3 + 8).toFixed(2)); // 8%-11% + const turnoverRate = parseFloat((Math.random() * 20 + 5).toFixed(2)); // 5%-25% + + stocks.push({ + stock_code: code, + stock_name: stockNames[i], + price: price, + increase_rate: increaseRate, + continuous_limit_up: continuousDays, + industry: industries[i], + turnover_rate: turnoverRate, + }); + } + + // 按连板天数降序排序 + stocks.sort((a, b) => b.continuous_limit_up - a.continuous_limit_up); + + return stocks; +}; + +// 生成高位股统计数据 +const generateHighPositionStatistics = (stocks) => { + if (!stocks || stocks.length === 0) { + return { + total_count: 0, + avg_continuous_days: 0, + max_continuous_days: 0, + }; + } + + const totalCount = stocks.length; + const sumDays = stocks.reduce((sum, stock) => sum + stock.continuous_limit_up, 0); + const maxDays = Math.max(...stocks.map(s => s.continuous_limit_up)); + + return { + total_count: totalCount, + avg_continuous_days: parseFloat((sumDays / totalCount).toFixed(1)), + max_continuous_days: maxDays, + }; +}; + +// 生成词云数据 +const generateWordCloudData = () => { + const keywords = [ + '人工智能', 'ChatGPT', 'AI芯片', '大模型', '算力', + '新能源', '光伏', '锂电池', '储能', '充电桩', + '半导体', '芯片', 'EDA', '国产替代', '集成电路', + '医疗', '创新药', 'CXO', '医疗器械', '生物医药', + '消费', '白酒', '食品', '零售', '餐饮', + '金融', '券商', '保险', '银行', '金融科技' + ]; + + return keywords.map(keyword => ({ + text: keyword, + value: Math.floor(Math.random() * 50) + 10, + category: ['科技', '新能源', '医疗', '消费', '金融'][Math.floor(Math.random() * 5)], + })); +}; + +// 生成每日分析数据 +const generateDailyAnalysis = (date) => { + const sectorNames = [ + '公告', '人工智能', 'ChatGPT', '数字经济', + '新能源汽车', '光伏', '锂电池', + '半导体', '芯片', '5G通信', + '医疗器械', '创新药', '其他' + ]; + + const stockNameTemplates = [ + '龙头', '科技', '新能源', '智能', '数字', '云计算', '创新', + '生物', '医疗', '通信', '电子', '材料', '能源', '互联' + ]; + + // 生成 sector_data(SectorDetails 组件需要的格式) + const sectorData = {}; + let totalStocks = 0; + + sectorNames.forEach((sectorName, sectorIdx) => { + const stockCount = Math.floor(Math.random() * 12) + 3; // 每个板块 3-15 只股票 + const stocks = []; + + for (let i = 0; i < stockCount; i++) { + const code = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`; + const continuousDays = Math.floor(Math.random() * 6) + 1; // 1-6连板 + const ztHour = Math.floor(Math.random() * 5) + 9; // 9-13点 + const ztMinute = Math.floor(Math.random() * 60); + const ztSecond = Math.floor(Math.random() * 60); + const ztTime = `2024-10-28 ${String(ztHour).padStart(2, '0')}:${String(ztMinute).padStart(2, '0')}:${String(ztSecond).padStart(2, '0')}`; + + const stockName = `${stockNameTemplates[i % stockNameTemplates.length]}${sectorName === '公告' ? '公告' : ''}股份${i + 1}`; + + stocks.push({ + scode: code, + sname: stockName, + zt_time: ztTime, + formatted_time: `${String(ztHour).padStart(2, '0')}:${String(ztMinute).padStart(2, '0')}`, + continuous_days: continuousDays === 1 ? '首板' : `${continuousDays}连板`, + brief: `${sectorName}板块异动,${stockName}因${sectorName === '公告' ? '重大公告利好' : '板块热点'}涨停。公司是${sectorName}行业龙头企业之一。`, + summary: `${sectorName}概念持续活跃`, + first_time: `2024-10-${String(28 - (continuousDays - 1)).padStart(2, '0')}`, + change_pct: parseFloat((Math.random() * 2 + 9).toFixed(2)), // 9%-11% + core_sectors: [ + sectorName, + sectorNames[Math.floor(Math.random() * sectorNames.length)], + sectorNames[Math.floor(Math.random() * sectorNames.length)] + ].filter((v, i, a) => a.indexOf(v) === i) // 去重 + }); + } + + sectorData[sectorName] = { + count: stockCount, + stocks: stocks.sort((a, b) => a.zt_time.localeCompare(b.zt_time)) // 按涨停时间排序 + }; + + totalStocks += stockCount; + }); + + // 统计数据 + const morningCount = Math.floor(totalStocks * 0.35); // 早盘涨停 + const announcementCount = sectorData['公告']?.count || 0; + const topSector = sectorNames.filter(s => s !== '公告' && s !== '其他') + .reduce((max, name) => + (sectorData[name]?.count || 0) > (sectorData[max]?.count || 0) ? name : max + , '人工智能'); + + return { + date: date, + total_stocks: totalStocks, + total_sectors: Object.keys(sectorData).length, + sector_data: sectorData, // 👈 SectorDetails 组件需要的数据 + summary: { + top_sector: topSector, + top_sector_count: sectorData[topSector]?.count || 0, + announcement_stocks: announcementCount, + zt_time_distribution: { + morning: morningCount, + afternoon: totalStocks - morningCount, + } + } + }; +}; + +// Mock Handlers +export const limitAnalyseHandlers = [ + // 1. 获取可用日期列表 + http.get('http://111.198.58.126:5001/api/v1/dates/available', async () => { + await delay(300); + + const availableDates = generateAvailableDates(); + + return HttpResponse.json({ + success: true, + events: availableDates, + message: '可用日期列表获取成功', + }); + }), + + // 2. 获取每日分析数据 + http.get('http://111.198.58.126:5001/api/v1/analysis/daily/:date', async ({ params }) => { + await delay(500); + + const { date } = params; + const data = generateDailyAnalysis(date); + + return HttpResponse.json({ + success: true, + data: data, + message: `${date} 每日分析数据获取成功`, + }); + }), + + // 3. 获取词云数据 + http.get('http://111.198.58.126:5001/api/v1/analysis/wordcloud/:date', async ({ params }) => { + await delay(300); + + const { date } = params; + const wordCloudData = generateWordCloudData(); + + return HttpResponse.json({ + success: true, + data: wordCloudData, + message: `${date} 词云数据获取成功`, + }); + }), + + // 4. 混合搜索(POST) + http.post('http://111.198.58.126:5001/api/v1/stocks/search/hybrid', async ({ request }) => { + await delay(400); + + const body = await request.json(); + const { query, type = 'all', mode = 'hybrid' } = body; + + // 生成模拟搜索结果 + const results = []; + const count = Math.floor(Math.random() * 10) + 5; + + for (let i = 0; i < count; i++) { + results.push({ + code: `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`, + name: `${query || '搜索'}相关股票${i + 1}`, + sector: ['人工智能', 'ChatGPT', '新能源'][Math.floor(Math.random() * 3)], + limit_date: new Date().toISOString().split('T')[0].replace(/-/g, ''), + limit_time: `${Math.floor(Math.random() * 4) + 9}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`, + price: (Math.random() * 100 + 10).toFixed(2), + change_pct: (Math.random() * 10).toFixed(2), + match_score: (Math.random() * 0.5 + 0.5).toFixed(2), + }); + } + + return HttpResponse.json({ + success: true, + data: { + query: query, + type: type, + mode: mode, + results: results, + total: results.length, + }, + message: '搜索完成', + }); + }), + + // 5. 获取高位股列表(涨停股票列表) + http.get('http://111.198.58.126:5001/api/limit-analyse/high-position-stocks', async ({ request }) => { + await delay(400); + + const url = new URL(request.url); + const date = url.searchParams.get('date'); + + console.log('[Mock LimitAnalyse] 获取高位股列表:', { date }); + + const stocks = generateHighPositionStocks(); + const statistics = generateHighPositionStatistics(stocks); + + return HttpResponse.json({ + success: true, + data: { + stocks: stocks, + statistics: statistics, + date: date, + }, + message: '高位股数据获取成功', + }); + }), +]; diff --git a/src/store/index.js b/src/store/index.js index 52a809b0..33789148 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -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; diff --git a/src/store/middleware/posthogMiddleware.js b/src/store/middleware/posthogMiddleware.js new file mode 100644 index 00000000..feec5813 --- /dev/null +++ b/src/store/middleware/posthogMiddleware.js @@ -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; diff --git a/src/store/slices/posthogSlice.js b/src/store/slices/posthogSlice.js new file mode 100644 index 00000000..8b5a4287 --- /dev/null +++ b/src/store/slices/posthogSlice.js @@ -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; diff --git a/src/views/Community/components/SearchBox.js b/src/views/Community/components/SearchBox.js index 84ff8c43..aa61ccc5 100644 --- a/src/views/Community/components/SearchBox.js +++ b/src/views/Community/components/SearchBox.js @@ -2,11 +2,21 @@ import React from 'react'; import { Card, Input, Radio, Form, Button } from 'antd'; import { SearchOutlined } from '@ant-design/icons'; +import { useSearchEvents } from '../../../hooks/useSearchEvents'; const SearchBox = ({ onSearch }) => { const [form] = Form.useForm(); + // 🎯 初始化搜索埋点Hook + const searchEvents = useSearchEvents({ context: 'community' }); + const handleSubmit = (values) => { + // 🎯 追踪搜索查询提交(在调用onSearch之前) + if (values.q) { + searchEvents.trackSearchQuerySubmitted(values.q, 0, { + search_type: values.search_type || 'topic' + }); + } onSearch(values); }; diff --git a/src/views/Community/components/StockDetailPanel.js b/src/views/Community/components/StockDetailPanel.js index 38b13512..8af9007b 100644 --- a/src/views/Community/components/StockDetailPanel.js +++ b/src/views/Community/components/StockDetailPanel.js @@ -17,6 +17,7 @@ import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeMod import moment from 'moment'; import { logger } from '../../../utils/logger'; import { getApiBase } from '../../../utils/apiConfig'; +import RiskDisclaimer from '../../../components/RiskDisclaimer'; const { Title, Text } = Typography; const { TabPane } = Tabs; @@ -1037,6 +1038,11 @@ function StockDetailPanel({ visible, event, onClose }) { className="stock-detail-panel" > + + {/* 风险提示 */} + + + {/* 事件讨论模态框 */} diff --git a/src/views/Community/hooks/useCommunityEvents.js b/src/views/Community/hooks/useCommunityEvents.js new file mode 100644 index 00000000..3c30ba5f --- /dev/null +++ b/src/views/Community/hooks/useCommunityEvents.js @@ -0,0 +1,281 @@ +// src/views/Community/hooks/useCommunityEvents.js +// 新闻催化分析页面事件追踪 Hook + +import { useCallback, useEffect } from 'react'; +import { usePostHogTrack } from '../../../hooks/usePostHogRedux'; +import { RETENTION_EVENTS } from '../../../lib/constants'; +import { logger } from '../../../utils/logger'; + +/** + * 新闻催化分析(Community)事件追踪 Hook + * @param {Object} options - 配置选项 + * @param {Function} options.navigate - 路由导航函数 + * @returns {Object} 事件追踪处理函数集合 + */ +export const useCommunityEvents = ({ navigate } = {}) => { + const { track } = usePostHogTrack(); + + // 🎯 页面浏览事件 - 页面加载时触发 + useEffect(() => { + track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, { + timestamp: new Date().toISOString(), + }); + logger.debug('useCommunityEvents', '📰 Community Page Viewed'); + }, [track]); + + /** + * 追踪新闻列表查看 + * @param {Object} params - 列表参数 + * @param {number} params.totalCount - 新闻总数 + * @param {string} params.sortBy - 排序方式 ('new' | 'hot' | 'returns') + * @param {string} params.importance - 重要性筛选 ('all' | 'high' | 'medium' | 'low') + * @param {string} params.dateRange - 日期范围 + * @param {string} params.industryFilter - 行业筛选 + */ + const trackNewsListViewed = useCallback((params = {}) => { + track(RETENTION_EVENTS.NEWS_LIST_VIEWED, { + total_count: params.totalCount || 0, + sort_by: params.sortBy || 'new', + importance_filter: params.importance || 'all', + date_range: params.dateRange || 'all', + industry_filter: params.industryFilter || 'all', + timestamp: new Date().toISOString(), + }); + + logger.debug('useCommunityEvents', '📋 News List Viewed', params); + }, [track]); + + /** + * 追踪新闻文章点击 + * @param {Object} news - 新闻对象 + * @param {number} news.id - 新闻ID + * @param {string} news.title - 新闻标题 + * @param {string} news.importance - 重要性等级 + * @param {number} position - 在列表中的位置 + * @param {string} source - 点击来源 ('list' | 'search' | 'recommendation') + */ + const trackNewsArticleClicked = useCallback((news, position = 0, source = 'list') => { + if (!news || !news.id) { + logger.warn('useCommunityEvents', 'trackNewsArticleClicked: news object is required'); + return; + } + + track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { + news_id: news.id, + news_title: news.title || '', + importance: news.importance || 'unknown', + position, + source, + timestamp: new Date().toISOString(), + }); + + logger.debug('useCommunityEvents', '🖱️ News Article Clicked', { + id: news.id, + position, + source, + }); + }, [track]); + + /** + * 追踪新闻详情打开 + * @param {Object} news - 新闻对象 + * @param {number} news.id - 新闻ID + * @param {string} news.title - 新闻标题 + * @param {string} news.importance - 重要性等级 + * @param {string} viewMode - 查看模式 ('modal' | 'page') + */ + const trackNewsDetailOpened = useCallback((news, viewMode = 'modal') => { + if (!news || !news.id) { + logger.warn('useCommunityEvents', 'trackNewsDetailOpened: news object is required'); + return; + } + + track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, { + news_id: news.id, + news_title: news.title || '', + importance: news.importance || 'unknown', + view_mode: viewMode, + timestamp: new Date().toISOString(), + }); + + logger.debug('useCommunityEvents', '📖 News Detail Opened', { + id: news.id, + viewMode, + }); + }, [track]); + + /** + * 追踪新闻标签页切换 + * @param {string} tabName - 标签名称 ('related_stocks' | 'related_concepts' | 'timeline') + * @param {number} newsId - 新闻ID + */ + const trackNewsTabClicked = useCallback((tabName, newsId = null) => { + if (!tabName) { + logger.warn('useCommunityEvents', 'trackNewsTabClicked: tabName is required'); + return; + } + + track(RETENTION_EVENTS.NEWS_TAB_CLICKED, { + tab_name: tabName, + news_id: newsId, + timestamp: new Date().toISOString(), + }); + + logger.debug('useCommunityEvents', '📑 News Tab Clicked', { + tabName, + newsId, + }); + }, [track]); + + /** + * 追踪新闻筛选应用 + * @param {Object} filters - 筛选条件 + * @param {string} filters.importance - 重要性筛选 + * @param {string} filters.dateRange - 日期范围 + * @param {string} filters.industryClassification - 行业分类 + * @param {string} filters.industryCode - 行业代码 + */ + const trackNewsFilterApplied = useCallback((filters = {}) => { + track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, { + importance: filters.importance || 'all', + date_range: filters.dateRange || 'all', + industry_classification: filters.industryClassification || 'all', + industry_code: filters.industryCode || 'all', + filter_count: Object.keys(filters).filter(key => filters[key] && filters[key] !== 'all').length, + timestamp: new Date().toISOString(), + }); + + logger.debug('useCommunityEvents', '🔍 News Filter Applied', filters); + }, [track]); + + /** + * 追踪新闻排序方式变更 + * @param {string} sortBy - 排序方式 ('new' | 'hot' | 'returns') + * @param {string} previousSort - 之前的排序方式 + */ + const trackNewsSorted = useCallback((sortBy, previousSort = 'new') => { + if (!sortBy) { + logger.warn('useCommunityEvents', 'trackNewsSorted: sortBy is required'); + return; + } + + track(RETENTION_EVENTS.NEWS_SORTED, { + sort_by: sortBy, + previous_sort: previousSort, + timestamp: new Date().toISOString(), + }); + + logger.debug('useCommunityEvents', '🔄 News Sorted', { + sortBy, + previousSort, + }); + }, [track]); + + /** + * 追踪搜索事件(新闻搜索) + * @param {string} query - 搜索关键词 + * @param {number} resultCount - 搜索结果数量 + */ + const trackNewsSearched = useCallback((query, resultCount = 0) => { + if (!query) return; + + track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, { + query, + result_count: resultCount, + has_results: resultCount > 0, + context: 'community_news', + timestamp: new Date().toISOString(), + }); + + // 如果没有搜索结果,额外追踪 + if (resultCount === 0) { + track(RETENTION_EVENTS.SEARCH_NO_RESULTS, { + query, + context: 'community_news', + timestamp: new Date().toISOString(), + }); + } + + logger.debug('useCommunityEvents', '🔍 News Searched', { + query, + resultCount, + }); + }, [track]); + + /** + * 追踪相关股票点击(从新闻详情) + * @param {Object} stock - 股票对象 + * @param {string} stock.code - 股票代码 + * @param {string} stock.name - 股票名称 + * @param {number} newsId - 关联的新闻ID + */ + const trackRelatedStockClicked = useCallback((stock, newsId = null) => { + if (!stock || !stock.code) { + logger.warn('useCommunityEvents', 'trackRelatedStockClicked: stock object is required'); + return; + } + + track(RETENTION_EVENTS.STOCK_CLICKED, { + stock_code: stock.code, + stock_name: stock.name || '', + source: 'news_related_stocks', + news_id: newsId, + timestamp: new Date().toISOString(), + }); + + logger.debug('useCommunityEvents', '🎯 Related Stock Clicked', { + stockCode: stock.code, + newsId, + }); + }, [track]); + + /** + * 追踪相关概念点击(从新闻详情) + * @param {Object} concept - 概念对象 + * @param {string} concept.code - 概念代码 + * @param {string} concept.name - 概念名称 + * @param {number} newsId - 关联的新闻ID + */ + const trackRelatedConceptClicked = useCallback((concept, newsId = null) => { + if (!concept || !concept.code) { + logger.warn('useCommunityEvents', 'trackRelatedConceptClicked: concept object is required'); + return; + } + + track(RETENTION_EVENTS.CONCEPT_CLICKED, { + concept_code: concept.code, + concept_name: concept.name || '', + source: 'news_related_concepts', + news_id: newsId, + timestamp: new Date().toISOString(), + }); + + logger.debug('useCommunityEvents', '🏷️ Related Concept Clicked', { + conceptCode: concept.code, + newsId, + }); + }, [track]); + + return { + // 页面级事件 + trackNewsListViewed, + + // 新闻交互事件 + trackNewsArticleClicked, + trackNewsDetailOpened, + trackNewsTabClicked, + + // 筛选和排序事件 + trackNewsFilterApplied, + trackNewsSorted, + + // 搜索事件 + trackNewsSearched, + + // 关联内容点击事件 + trackRelatedStockClicked, + trackRelatedConceptClicked, + }; +}; + +export default useCommunityEvents; diff --git a/src/views/Community/hooks/useEventFilters.js b/src/views/Community/hooks/useEventFilters.js index 20ca3c8b..e65401fd 100644 --- a/src/views/Community/hooks/useEventFilters.js +++ b/src/views/Community/hooks/useEventFilters.js @@ -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, diff --git a/src/views/Community/index.js b/src/views/Community/index.js index 4c759ce5..0739f7d5 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -17,15 +17,19 @@ import EventModals from './components/EventModals'; // 导入自定义 Hooks import { useEventData } from './hooks/useEventData'; import { useEventFilters } from './hooks/useEventFilters'; +import { useCommunityEvents } from './hooks/useCommunityEvents'; 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); @@ -44,6 +48,9 @@ const Community = () => { const [selectedEvent, setSelectedEvent] = useState(null); const [selectedEventForStock, setSelectedEventForStock] = useState(null); + // 🎯 初始化Community埋点Hook + const communityEvents = useCommunityEvents({ navigate }); + // 自定义 Hooks const { filters, updateFilters, handlePageChange, handleEventClick, handleViewDetail } = useEventFilters({ navigate, @@ -59,6 +66,28 @@ 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 (events && events.length > 0 && !loading) { + communityEvents.trackNewsListViewed({ + totalCount: pagination?.total || events.length, + sortBy: filters.sort, + importance: filters.importance, + dateRange: filters.date_range, + industryFilter: filters.industry_code, + }); + } + }, [events, loading, pagination, filters, communityEvents]); + // ⚡ 首次访问社区时,延迟显示权限引导 useEffect(() => { if (showCommunityGuide) { diff --git a/src/views/Company/hooks/useCompanyEvents.js b/src/views/Company/hooks/useCompanyEvents.js new file mode 100644 index 00000000..5b5ed769 --- /dev/null +++ b/src/views/Company/hooks/useCompanyEvents.js @@ -0,0 +1,103 @@ +// src/views/Company/hooks/useCompanyEvents.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 {string} options.stockCode - 当前股票代码 + * @returns {Object} 事件追踪处理函数集合 + */ +export const useCompanyEvents = ({ stockCode } = {}) => { + const { track } = usePostHogTrack(); + + // 🎯 页面浏览事件 - 页面加载时触发 + useEffect(() => { + track(RETENTION_EVENTS.COMPANY_PAGE_VIEWED, { + timestamp: new Date().toISOString(), + stock_code: stockCode || null, + }); + logger.debug('useCompanyEvents', '📊 Company Page Viewed', { stockCode }); + }, [track, stockCode]); + + /** + * 追踪股票搜索/切换 + * @param {string} newStockCode - 新的股票代码 + * @param {string} previousStockCode - 之前的股票代码 + */ + const trackStockSearched = useCallback((newStockCode, previousStockCode = null) => { + if (!newStockCode) return; + + track(RETENTION_EVENTS.STOCK_SEARCHED, { + query: newStockCode, + stock_code: newStockCode, + previous_stock_code: previousStockCode, + context: 'company_page', + }); + + logger.debug('useCompanyEvents', '🔍 Stock Searched', { + newStockCode, + previousStockCode, + }); + }, [track]); + + /** + * 追踪 Tab 切换 + * @param {number} tabIndex - Tab 索引 (0: 公司概览, 1: 股票行情, 2: 财务全景, 3: 盈利预测) + * @param {string} tabName - Tab 名称 + * @param {number} previousTabIndex - 之前的 Tab 索引 + */ + const trackTabChanged = useCallback((tabIndex, tabName, previousTabIndex = null) => { + track(RETENTION_EVENTS.TAB_CHANGED, { + tab_index: tabIndex, + tab_name: tabName, + previous_tab_index: previousTabIndex, + stock_code: stockCode, + context: 'company_page', + }); + + logger.debug('useCompanyEvents', '🔄 Tab Changed', { + tabIndex, + tabName, + previousTabIndex, + stockCode, + }); + }, [track, stockCode]); + + /** + * 追踪加入自选股 + * @param {string} stock_code - 股票代码 + */ + const trackWatchlistAdded = useCallback((stock_code) => { + track(RETENTION_EVENTS.WATCHLIST_ADDED, { + stock_code, + source: 'company_page', + }); + + logger.debug('useCompanyEvents', '⭐ Watchlist Added', { stock_code }); + }, [track]); + + /** + * 追踪移除自选股 + * @param {string} stock_code - 股票代码 + */ + const trackWatchlistRemoved = useCallback((stock_code) => { + track(RETENTION_EVENTS.WATCHLIST_REMOVED, { + stock_code, + source: 'company_page', + }); + + logger.debug('useCompanyEvents', '❌ Watchlist Removed', { stock_code }); + }, [track]); + + return { + trackStockSearched, + trackTabChanged, + trackWatchlistAdded, + trackWatchlistRemoved, + }; +}; diff --git a/src/views/Company/index.js b/src/views/Company/index.js index 4a9cd4be..bdcc8253 100644 --- a/src/views/Company/index.js +++ b/src/views/Company/index.js @@ -34,6 +34,8 @@ import FinancialPanorama from './FinancialPanorama'; import ForecastReport from './ForecastReport'; import MarketDataView from './MarketDataView'; import CompanyOverview from './CompanyOverview'; +// 导入 PostHog 追踪 Hook +import { useCompanyEvents } from './hooks/useCompanyEvents'; const CompanyIndex = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -42,7 +44,18 @@ const CompanyIndex = () => { const { colorMode, toggleColorMode } = useColorMode(); const toast = useToast(); const { isAuthenticated } = useAuth(); - + + // 🎯 PostHog 事件追踪 + const { + trackStockSearched, + trackTabChanged, + trackWatchlistAdded, + trackWatchlistRemoved, + } = useCompanyEvents({ stockCode }); + + // Tab 索引状态(用于追踪 Tab 切换) + const [currentTabIndex, setCurrentTabIndex] = useState(0); + const bgColor = useColorModeValue('white', 'gray.800'); const tabBg = useColorModeValue('gray.50', 'gray.700'); const activeBg = useColorModeValue('blue.500', 'blue.400'); @@ -86,6 +99,9 @@ const CompanyIndex = () => { const handleSearch = () => { if (inputCode && inputCode !== stockCode) { + // 🎯 追踪股票搜索 + trackStockSearched(inputCode, stockCode); + setStockCode(inputCode); setSearchParams({ scode: inputCode }); } @@ -123,6 +139,10 @@ const CompanyIndex = () => { logger.api.response('DELETE', url, resp.status); if (!resp.ok) throw new Error('删除失败'); + + // 🎯 追踪移除自选 + trackWatchlistRemoved(stockCode); + setIsInWatchlist(false); toast({ title: '已从自选移除', status: 'info', duration: 1500 }); } else { @@ -140,6 +160,10 @@ const CompanyIndex = () => { logger.api.response('POST', url, resp.status); if (!resp.ok) throw new Error('添加失败'); + + // 🎯 追踪加入自选 + trackWatchlistAdded(stockCode); + setIsInWatchlist(true); toast({ title: '已加入自选', status: 'success', duration: 1500 }); } @@ -226,7 +250,18 @@ const CompanyIndex = () => { {/* 数据展示区域 */} - + { + const tabNames = ['公司概览', '股票行情', '财务全景', '盈利预测']; + // 🎯 追踪 Tab 切换 + trackTabChanged(index, tabNames[index], currentTabIndex); + setCurrentTabIndex(index); + }} + > { 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 +331,11 @@ const ConceptTimelineModal = ({ // 切换日期展开状态 const toggleDateExpand = (date) => { + const willExpand = !expandedDates[date]; + + // 🎯 追踪日期展开/折叠 + trackDateToggled(date, willExpand); + setExpandedDates(prev => ({ ...prev, [date]: !prev[date] @@ -728,6 +746,10 @@ const ConceptTimelineModal = ({ leftIcon={} onClick={() => { if (event.type === 'news') { + // 🎯 追踪新闻点击和详情打开 + trackNewsClicked(event, date); + trackNewsDetailOpened(event); + setSelectedNews({ title: event.title, content: event.content, @@ -737,6 +759,10 @@ const ConceptTimelineModal = ({ }); setIsNewsModalOpen(true); } else if (event.type === 'report') { + // 🎯 追踪研报点击和详情打开 + trackReportClicked(event, date); + trackReportDetailOpened(event); + setSelectedReport({ title: event.title, content: event.content, @@ -800,6 +826,11 @@ const ConceptTimelineModal = ({ )} + + {/* 风险提示 */} + + + diff --git a/src/views/Concept/components/ConceptStatsPanel.js b/src/views/Concept/components/ConceptStatsPanel.js index 23117993..68a93935 100644 --- a/src/views/Concept/components/ConceptStatsPanel.js +++ b/src/views/Concept/components/ConceptStatsPanel.js @@ -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 }) => { {/* 主内容卡片 */} - + { + const tabNames = ['涨幅榜', '跌幅榜', '活跃榜', '波动榜', '连涨榜']; + // 🎯 追踪Tab切换 + trackTabChanged(index, tabNames[index]); + setActiveTab(index); + }} + variant="unstyled" + size="sm" + > { + 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]); + + // ========== 别名函数 - 为保持向后兼容性 ========== + + /** + * 追踪概念搜索(别名函数) + * @alias trackSearchQuerySubmitted + */ + const trackConceptSearched = useCallback((query, resultCount = 0) => { + return trackSearchQuerySubmitted(query, resultCount); + }, [trackSearchQuerySubmitted]); + + /** + * 追踪筛选器应用(通用包装函数) + * @param {string} filterType - 筛选类型 (sort/date/view_mode) + * @param {any} filterValue - 筛选值 + * @param {any} previousValue - 之前的值 + */ + const trackFilterApplied = useCallback((filterType, filterValue, previousValue = null) => { + if (filterType === 'sort') { + return trackSortChanged(filterValue, previousValue); + } else if (filterType === 'date') { + return trackDateChanged(filterValue, previousValue); + } else if (filterType === 'view_mode') { + return trackViewModeChanged(filterValue, previousValue); + } + }, [trackSortChanged, trackDateChanged, trackViewModeChanged]); + + /** + * 追踪概念股票列表查看 + * @param {string} conceptName - 概念名称 + * @param {number} stockCount - 股票数量 + */ + const trackConceptStocksViewed = useCallback((conceptName, stockCount = 0) => { + track(RETENTION_EVENTS.CONCEPT_DETAIL_VIEWED, { + concept_name: conceptName, + stock_count: stockCount, + view_type: 'stocks_list', + source: 'concept_center', + }); + + logger.debug('useConceptEvents', '📈 Concept Stocks Viewed', { + conceptName, + stockCount, + }); + }, [track]); + + /** + * 追踪概念时间轴查看(别名函数) + * @alias trackConceptDetailViewed + */ + const trackConceptTimelineViewed = useCallback((conceptName, conceptId) => { + return trackConceptDetailViewed(conceptName, conceptId); + }, [trackConceptDetailViewed]); + + /** + * 追踪分页变化(别名函数 - 不同时态) + * @alias trackPageChanged + */ + const trackPageChange = useCallback((page, filters = {}) => { + return trackPageChanged(page, filters); + }, [trackPageChanged]); + + return { + // 原有函数 + trackConceptListViewed, + trackSearchInitiated, + trackSearchQuerySubmitted, + trackSortChanged, + trackViewModeChanged, + trackDateChanged, + trackPageChanged, + trackConceptClicked, + trackConceptStockClicked, + trackConceptDetailViewed, + trackStockDetailViewed, + trackPaywallShown, + trackUpgradeClicked, + + // 别名函数 - 为保持向后兼容性 + trackConceptSearched, + trackFilterApplied, + trackConceptStocksViewed, + trackConceptTimelineViewed, + trackPageChange, + }; +}; diff --git a/src/views/Concept/hooks/useConceptStatsEvents.js b/src/views/Concept/hooks/useConceptStatsEvents.js new file mode 100644 index 00000000..4b065478 Binary files /dev/null and b/src/views/Concept/hooks/useConceptStatsEvents.js differ diff --git a/src/views/Concept/hooks/useConceptTimelineEvents.js b/src/views/Concept/hooks/useConceptTimelineEvents.js new file mode 100644 index 00000000..c62d3731 Binary files /dev/null and b/src/views/Concept/hooks/useConceptTimelineEvents.js differ diff --git a/src/views/Concept/index.js b/src/views/Concept/index.js index 3d13e2f3..2c0fdde0 100644 --- a/src/views/Concept/index.js +++ b/src/views/Concept/index.js @@ -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 ( 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 ( 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 = () => { } - 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 = () => { /> } - 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' ? ( - {concepts.map((concept) => ( + {concepts.map((concept, index) => ( - + ))} ) : ( - {concepts.map((concept) => ( - + {concepts.map((concept, index) => ( + ))} )} diff --git a/src/views/Dashboard/Center.js b/src/views/Dashboard/Center.js index 3eca3e4a..43449784 100644 --- a/src/views/Dashboard/Center.js +++ b/src/views/Dashboard/Center.js @@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import { logger } from '../../utils/logger'; import { getApiBase } from '../../utils/apiConfig'; +import { useDashboardEvents } from '../../hooks/useDashboardEvents'; import { Box, Flex, @@ -72,6 +73,12 @@ export default function CenterDashboard() { const userId = user?.id; const prevUserIdRef = React.useRef(userId); + // 🎯 初始化Dashboard埋点Hook + const dashboardEvents = useDashboardEvents({ + pageType: 'center', + navigate + }); + // 颜色主题 const textColor = useColorModeValue('gray.700', 'white'); const borderColor = useColorModeValue('gray.200', 'gray.600'); @@ -101,14 +108,33 @@ export default function CenterDashboard() { const je = await e.json(); const jc = await c.json(); if (jw.success) { - setWatchlist(Array.isArray(jw.data) ? jw.data : []); + const watchlistData = Array.isArray(jw.data) ? jw.data : []; + setWatchlist(watchlistData); + + // 🎯 追踪自选股列表查看 + if (watchlistData.length > 0) { + dashboardEvents.trackWatchlistViewed(watchlistData.length, true); + } + // 加载实时行情 if (jw.data && jw.data.length > 0) { loadRealtimeQuotes(); } } - if (je.success) setFollowingEvents(Array.isArray(je.data) ? je.data : []); - if (jc.success) setEventComments(Array.isArray(jc.data) ? jc.data : []); + if (je.success) { + const eventsData = Array.isArray(je.data) ? je.data : []; + setFollowingEvents(eventsData); + + // 🎯 追踪关注的事件列表查看 + dashboardEvents.trackFollowingEventsViewed(eventsData.length); + } + if (jc.success) { + const commentsData = Array.isArray(jc.data) ? jc.data : []; + setEventComments(commentsData); + + // 🎯 追踪评论列表查看 + dashboardEvents.trackCommentsViewed(commentsData.length); + } } catch (err) { logger.error('Center', 'loadData', err, { userId, diff --git a/src/views/EventDetail/hooks/useEventDetailEvents.js b/src/views/EventDetail/hooks/useEventDetailEvents.js new file mode 100644 index 00000000..82d441b1 --- /dev/null +++ b/src/views/EventDetail/hooks/useEventDetailEvents.js @@ -0,0 +1,346 @@ +// src/views/EventDetail/hooks/useEventDetailEvents.js +// 事件详情页面事件追踪 Hook + +import { useCallback, useEffect } from 'react'; +import { usePostHogTrack } from '../../../hooks/usePostHogRedux'; +import { RETENTION_EVENTS } from '../../../lib/constants'; +import { logger } from '../../../utils/logger'; + +/** + * 事件详情(EventDetail)事件追踪 Hook + * @param {Object} options - 配置选项 + * @param {Object} options.event - 事件对象 + * @param {number} options.event.id - 事件ID + * @param {string} options.event.title - 事件标题 + * @param {string} options.event.importance - 重要性等级 + * @param {Function} options.navigate - 路由导航函数 + * @returns {Object} 事件追踪处理函数集合 + */ +export const useEventDetailEvents = ({ event, navigate } = {}) => { + const { track } = usePostHogTrack(); + + // 🎯 页面浏览事件 - 页面加载时触发 + useEffect(() => { + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for page view tracking'); + return; + } + + track(RETENTION_EVENTS.EVENT_DETAIL_VIEWED, { + event_id: event.id, + event_title: event.title || '', + importance: event.importance || 'unknown', + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '📄 Event Detail Page Viewed', { + eventId: event.id, + }); + }, [track, event]); + + /** + * 追踪事件分析内容查看 + * @param {Object} analysisData - 分析数据 + * @param {string} analysisData.type - 分析类型 ('market_impact' | 'stock_correlation' | 'timeline') + * @param {number} analysisData.relatedStockCount - 相关股票数量 + * @param {number} analysisData.timelineEventCount - 时间线事件数量 + */ + const trackEventAnalysisViewed = useCallback((analysisData = {}) => { + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for analysis tracking'); + return; + } + + track(RETENTION_EVENTS.EVENT_ANALYSIS_VIEWED, { + event_id: event.id, + analysis_type: analysisData.type || 'overview', + related_stock_count: analysisData.relatedStockCount || 0, + timeline_event_count: analysisData.timelineEventCount || 0, + has_market_impact: Boolean(analysisData.marketImpact), + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '📊 Event Analysis Viewed', { + eventId: event.id, + analysisType: analysisData.type, + }); + }, [track, event]); + + /** + * 追踪事件时间线点击 + * @param {Object} timelineItem - 时间线项目 + * @param {string} timelineItem.id - 时间线项目ID + * @param {string} timelineItem.date - 时间线日期 + * @param {string} timelineItem.title - 时间线标题 + * @param {number} position - 在时间线中的位置 + */ + const trackEventTimelineClicked = useCallback((timelineItem, position = 0) => { + if (!timelineItem || !timelineItem.id) { + logger.warn('useEventDetailEvents', 'Timeline item is required'); + return; + } + + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for timeline tracking'); + return; + } + + track(RETENTION_EVENTS.EVENT_TIMELINE_CLICKED, { + event_id: event.id, + timeline_item_id: timelineItem.id, + timeline_date: timelineItem.date || '', + timeline_title: timelineItem.title || '', + position, + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '⏰ Event Timeline Clicked', { + eventId: event.id, + timelineItemId: timelineItem.id, + position, + }); + }, [track, event]); + + /** + * 追踪相关股票点击(从事件详情) + * @param {Object} stock - 股票对象 + * @param {string} stock.code - 股票代码 + * @param {string} stock.name - 股票名称 + * @param {number} position - 在列表中的位置 + */ + const trackRelatedStockClicked = useCallback((stock, position = 0) => { + if (!stock || !stock.code) { + logger.warn('useEventDetailEvents', 'Stock object is required'); + return; + } + + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for stock tracking'); + return; + } + + track(RETENTION_EVENTS.STOCK_CLICKED, { + stock_code: stock.code, + stock_name: stock.name || '', + source: 'event_detail_related_stocks', + event_id: event.id, + position, + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '🎯 Related Stock Clicked', { + stockCode: stock.code, + eventId: event.id, + position, + }); + }, [track, event]); + + /** + * 追踪相关概念点击(从事件详情) + * @param {Object} concept - 概念对象 + * @param {string} concept.code - 概念代码 + * @param {string} concept.name - 概念名称 + * @param {number} position - 在列表中的位置 + */ + const trackRelatedConceptClicked = useCallback((concept, position = 0) => { + if (!concept || !concept.code) { + logger.warn('useEventDetailEvents', 'Concept object is required'); + return; + } + + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for concept tracking'); + return; + } + + track(RETENTION_EVENTS.CONCEPT_CLICKED, { + concept_code: concept.code, + concept_name: concept.name || '', + source: 'event_detail_related_concepts', + event_id: event.id, + position, + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '🏷️ Related Concept Clicked', { + conceptCode: concept.code, + eventId: event.id, + position, + }); + }, [track, event]); + + /** + * 追踪标签页切换 + * @param {string} tabName - 标签名称 ('overview' | 'related_stocks' | 'related_concepts' | 'timeline') + */ + const trackTabClicked = useCallback((tabName) => { + if (!tabName) { + logger.warn('useEventDetailEvents', 'Tab name is required'); + return; + } + + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for tab tracking'); + return; + } + + track(RETENTION_EVENTS.NEWS_TAB_CLICKED, { + tab_name: tabName, + event_id: event.id, + context: 'event_detail', + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '📑 Tab Clicked', { + tabName, + eventId: event.id, + }); + }, [track, event]); + + /** + * 追踪事件收藏/取消收藏 + * @param {boolean} isFavorited - 是否收藏 + */ + const trackEventFavoriteToggled = useCallback((isFavorited) => { + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for favorite tracking'); + return; + } + + const eventName = isFavorited ? 'Event Favorited' : 'Event Unfavorited'; + + track(eventName, { + event_id: event.id, + event_title: event.title || '', + action: isFavorited ? 'add' : 'remove', + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', `${isFavorited ? '⭐' : '☆'} Event Favorite Toggled`, { + eventId: event.id, + isFavorited, + }); + }, [track, event]); + + /** + * 追踪事件分享 + * @param {string} shareMethod - 分享方式 ('wechat' | 'link' | 'qrcode') + */ + const trackEventShared = useCallback((shareMethod) => { + if (!shareMethod) { + logger.warn('useEventDetailEvents', 'Share method is required'); + return; + } + + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for share tracking'); + return; + } + + track(RETENTION_EVENTS.CONTENT_SHARED, { + content_type: 'event', + content_id: event.id, + content_title: event.title || '', + share_method: shareMethod, + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '📤 Event Shared', { + eventId: event.id, + shareMethod, + }); + }, [track, event]); + + /** + * 追踪评论点赞/取消点赞 + * @param {string} commentId - 评论ID + * @param {boolean} isLiked - 是否点赞 + */ + const trackCommentLiked = useCallback((commentId, isLiked) => { + if (!commentId) { + logger.warn('useEventDetailEvents', 'Comment ID is required'); + return; + } + + track(isLiked ? 'Comment Liked' : 'Comment Unliked', { + comment_id: commentId, + event_id: event?.id, + action: isLiked ? 'like' : 'unlike', + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', `${isLiked ? '❤️' : '🤍'} Comment ${isLiked ? 'Liked' : 'Unliked'}`, { + commentId, + eventId: event?.id, + }); + }, [track, event]); + + /** + * 追踪添加评论 + * @param {string} commentId - 评论ID + * @param {number} contentLength - 评论内容长度 + */ + const trackCommentAdded = useCallback((commentId, contentLength = 0) => { + if (!event || !event.id) { + logger.warn('useEventDetailEvents', 'Event object is required for comment tracking'); + return; + } + + track('Comment Added', { + comment_id: commentId, + event_id: event.id, + content_length: contentLength, + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '💬 Comment Added', { + commentId, + eventId: event.id, + contentLength, + }); + }, [track, event]); + + /** + * 追踪删除评论 + * @param {string} commentId - 评论ID + */ + const trackCommentDeleted = useCallback((commentId) => { + if (!commentId) { + logger.warn('useEventDetailEvents', 'Comment ID is required'); + return; + } + + track('Comment Deleted', { + comment_id: commentId, + event_id: event?.id, + timestamp: new Date().toISOString(), + }); + + logger.debug('useEventDetailEvents', '🗑️ Comment Deleted', { + commentId, + eventId: event?.id, + }); + }, [track, event]); + + return { + // 页面级事件 + trackEventAnalysisViewed, + + // 交互事件 + trackEventTimelineClicked, + trackRelatedStockClicked, + trackRelatedConceptClicked, + trackTabClicked, + + // 用户行为事件 + trackEventFavoriteToggled, + trackEventShared, + + // 社交互动事件 + trackCommentLiked, + trackCommentAdded, + trackCommentDeleted, + }; +}; + +export default useEventDetailEvents; diff --git a/src/views/EventDetail/index.js b/src/views/EventDetail/index.js index 7c6ea891..52bd6f2f 100644 --- a/src/views/EventDetail/index.js +++ b/src/views/EventDetail/index.js @@ -75,6 +75,7 @@ import TransmissionChainAnalysis from './components/TransmissionChainAnalysis'; import { eventService } from '../../services/eventService'; import { debugEventService } from '../../utils/debugEventService'; import { logger } from '../../utils/logger'; +import { useEventDetailEvents } from './hooks/useEventDetailEvents'; // 临时调试代码 - 生产环境测试后请删除 if (typeof window !== 'undefined') { @@ -110,7 +111,7 @@ const StatCard = ({ icon, label, value, color }) => { }; // 帖子组件 -const PostItem = ({ post, onRefresh }) => { +const PostItem = ({ post, onRefresh, eventEvents }) => { const [showComments, setShowComments] = useState(false); const [comments, setComments] = useState([]); const [newComment, setNewComment] = useState(''); @@ -145,8 +146,14 @@ const PostItem = ({ post, onRefresh }) => { try { const result = await eventService.likePost(post.id); if (result.success) { - setLiked(result.liked); + const newLikedState = result.liked; + setLiked(newLikedState); setLikesCount(result.likes_count); + + // 🎯 追踪评论点赞 + if (eventEvents && eventEvents.trackCommentLiked) { + eventEvents.trackCommentLiked(post.id, newLikedState); + } } } catch (error) { toast({ @@ -166,6 +173,14 @@ const PostItem = ({ post, onRefresh }) => { }); if (result.success) { + // 🎯 追踪添加评论 + if (eventEvents && eventEvents.trackCommentAdded) { + eventEvents.trackCommentAdded( + result.data?.id || post.id, + newComment.length + ); + } + toast({ title: '评论发表成功', status: 'success', @@ -192,6 +207,11 @@ const PostItem = ({ post, onRefresh }) => { try { const result = await eventService.deletePost(post.id); if (result.success) { + // 🎯 追踪删除评论 + if (eventEvents && eventEvents.trackCommentDeleted) { + eventEvents.trackCommentDeleted(post.id); + } + toast({ title: '删除成功', status: 'success', @@ -348,6 +368,15 @@ const EventDetail = () => { const [postsLoading, setPostsLoading] = useState(false); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState(0); + + // 🎯 初始化事件详情埋点Hook(传入event对象) + const eventEvents = useEventDetailEvents({ + event: eventData ? { + id: eventData.id, + title: eventData.title, + importance: eventData.importance + } : null + }); const [newPostContent, setNewPostContent] = useState(''); const [newPostTitle, setNewPostTitle] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -380,9 +409,11 @@ const EventDetail = () => { setEventData(eventResponse.data); // 总是尝试加载相关股票(权限在组件内部检查) + let stocksCount = 0; try { const stocksResponse = await eventService.getRelatedStocks(actualEventId); setRelatedStocks(stocksResponse.data || []); + stocksCount = stocksResponse.data?.length || 0; } catch (e) { logger.warn('EventDetail', '加载相关股票失败', { eventId: actualEventId, error: e.message }); setRelatedStocks([]); @@ -399,13 +430,25 @@ const EventDetail = () => { } // 历史事件所有用户都可以访问,但免费用户只看到前2条 + let timelineCount = 0; try { const eventsResponse = await eventService.getHistoricalEvents(actualEventId); setHistoricalEvents(eventsResponse.data || []); + timelineCount = eventsResponse.data?.length || 0; } catch (e) { logger.warn('EventDetail', '历史事件加载失败', { eventId: actualEventId, error: e.message }); } + // 🎯 追踪事件分析内容查看(数据加载完成后) + if (eventResponse.data && eventEvents) { + eventEvents.trackEventAnalysisViewed({ + type: 'overview', + relatedStockCount: stocksCount, + timelineEventCount: timelineCount, + marketImpact: eventResponse.data.market_impact + }); + } + } catch (err) { logger.error('EventDetail', 'loadEventData', err, { eventId: actualEventId }); setError(err.message || '加载事件数据失败'); @@ -800,7 +843,12 @@ const EventDetail = () => { ) : posts.length > 0 ? ( posts.map((post) => ( - + )) ) : ( { + 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 ( @@ -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' }} > @@ -343,7 +365,7 @@ export default function HomePage() { minH="44px" onClick={(e) => { e.stopPropagation(); - handleProductClick(feature.url); + handleProductClick(feature); }} > 使用 diff --git a/src/views/LimitAnalyse/components/DataVisualizationComponents.js b/src/views/LimitAnalyse/components/DataVisualizationComponents.js index 4549a148..4c02148f 100644 --- a/src/views/LimitAnalyse/components/DataVisualizationComponents.js +++ b/src/views/LimitAnalyse/components/DataVisualizationComponents.js @@ -38,6 +38,7 @@ import { } from '@chakra-ui/react'; import { getFormattedTextProps } from '../../../utils/textUtils'; import { ExternalLinkIcon } from '@chakra-ui/icons'; +import RiskDisclaimer from '../../../components/RiskDisclaimer'; import './WordCloud.css'; import { BarChart, Bar, @@ -598,6 +599,9 @@ export const StockDetailModal = ({ isOpen, onClose, selectedStock }) => { ))} + + {/* 风险提示 */} + diff --git a/src/views/LimitAnalyse/components/SectorDetails.js b/src/views/LimitAnalyse/components/SectorDetails.js index e5cadadd..5c1406bf 100644 --- a/src/views/LimitAnalyse/components/SectorDetails.js +++ b/src/views/LimitAnalyse/components/SectorDetails.js @@ -29,7 +29,7 @@ import { import { StarIcon, ViewIcon, TimeIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import { getFormattedTextProps } from '../../../utils/textUtils'; -const SectorDetails = ({ sortedSectors, totalStocks }) => { +const SectorDetails = ({ sortedSectors, totalStocks, onStockClick }) => { // 使用 useRef 来维持展开状态,避免重新渲染时重置 const expandedSectorsRef = useRef([]); const [expandedSectors, setExpandedSectors] = useState([]); @@ -194,6 +194,8 @@ const SectorDetails = ({ sortedSectors, totalStocks }) => { bg: 'gray.50' }} transition="all 0.2s" + cursor="pointer" + onClick={() => onStockClick && onStockClick(stock)} > diff --git a/src/views/LimitAnalyse/hooks/useLimitAnalyseEvents.js b/src/views/LimitAnalyse/hooks/useLimitAnalyseEvents.js new file mode 100644 index 00000000..7a0c245e --- /dev/null +++ b/src/views/LimitAnalyse/hooks/useLimitAnalyseEvents.js @@ -0,0 +1,252 @@ +// src/views/LimitAnalyse/hooks/useLimitAnalyseEvents.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 useLimitAnalyseEvents = ({ navigate } = {}) => { + const { track } = usePostHogTrack(); + + // 页面浏览追踪 - 组件加载时自动触发 + useEffect(() => { + track(RETENTION_EVENTS.LIMIT_ANALYSE_PAGE_VIEWED, { + timestamp: new Date().toISOString(), + }); + logger.debug('useLimitAnalyseEvents', '👁️ Limit Analyse Page Viewed'); + }, [track]); + + /** + * 追踪日期选择 + * @param {string} date - 选择的日期(YYYYMMDD 格式) + * @param {string} previousDate - 之前的日期 + */ + const trackDateSelected = useCallback((date, previousDate = null) => { + track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, { + filter_type: 'date', + filter_value: date, + previous_value: previousDate, + context: 'limit_analyse', + }); + + logger.debug('useLimitAnalyseEvents', '📅 Date Selected', { + date, + previousDate, + }); + }, [track]); + + /** + * 追踪每日统计数据查看 + * @param {Object} stats - 统计数据 + * @param {string} date - 日期 + */ + const trackDailyStatsViewed = useCallback((stats, date) => { + if (!stats) return; + + track(RETENTION_EVENTS.LIMIT_ANALYSE_PAGE_VIEWED, { + date, + total_stocks: stats.total_stocks, + sector_count: stats.sectors?.length || 0, + hot_sector: stats.hot_sector?.name, + view_type: 'daily_stats', + }); + + logger.debug('useLimitAnalyseEvents', '📊 Daily Stats Viewed', { + date, + totalStocks: stats.total_stocks, + }); + }, [track]); + + /** + * 追踪板块展开/收起 + * @param {string} sectorName - 板块名称 + * @param {boolean} isExpanded - 是否展开 + * @param {number} stockCount - 板块内股票数量 + */ + const trackSectorToggled = useCallback((sectorName, isExpanded, stockCount = 0) => { + track(RETENTION_EVENTS.LIMIT_SECTOR_EXPANDED, { + sector_name: sectorName, + action: isExpanded ? 'expand' : 'collapse', + stock_count: stockCount, + source: 'limit_analyse', + }); + + logger.debug('useLimitAnalyseEvents', '🔽 Sector Toggled', { + sectorName, + isExpanded, + stockCount, + }); + }, [track]); + + /** + * 追踪板块点击 + * @param {Object} sector - 板块对象 + */ + const trackSectorClicked = useCallback((sector) => { + track(RETENTION_EVENTS.LIMIT_BOARD_CLICKED, { + sector_name: sector.name, + stock_count: sector.count, + source: 'limit_analyse', + }); + + logger.debug('useLimitAnalyseEvents', '🎯 Sector Clicked', { + sectorName: sector.name, + }); + }, [track]); + + /** + * 追踪涨停股票点击 + * @param {Object} stock - 股票对象 + * @param {string} sectorName - 所属板块 + */ + const trackLimitStockClicked = useCallback((stock, sectorName = '') => { + track(RETENTION_EVENTS.LIMIT_STOCK_CLICKED, { + stock_code: stock.code || stock.stock_code, + stock_name: stock.name || stock.stock_name, + sector_name: sectorName, + limit_time: stock.limit_time, + source: 'limit_analyse', + }); + + logger.debug('useLimitAnalyseEvents', '📈 Limit Stock Clicked', { + stockCode: stock.code || stock.stock_code, + sectorName, + }); + }, [track]); + + /** + * 追踪搜索发起 + * @param {string} query - 搜索关键词 + * @param {string} searchType - 搜索类型(all/sector/stock) + * @param {string} searchMode - 搜索模式(hybrid/standard) + */ + const trackSearchInitiated = useCallback((query, searchType = 'all', searchMode = 'hybrid') => { + track(RETENTION_EVENTS.SEARCH_INITIATED, { + context: 'limit_analyse', + }); + + track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, { + query, + category: 'limit_analyse', + search_type: searchType, + search_mode: searchMode, + }); + + logger.debug('useLimitAnalyseEvents', '🔍 Search Initiated', { + query, + searchType, + searchMode, + }); + }, [track]); + + /** + * 追踪搜索结果点击 + * @param {Object} result - 搜索结果对象 + * @param {number} position - 在结果列表中的位置 + */ + const trackSearchResultClicked = useCallback((result, position = 0) => { + track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, { + result_type: result.type, + result_id: result.id || result.code, + result_name: result.name, + position, + context: 'limit_analyse', + }); + + logger.debug('useLimitAnalyseEvents', '🎯 Search Result Clicked', { + type: result.type, + name: result.name, + position, + }); + }, [track]); + + /** + * 追踪高位股查看 + * @param {string} date - 日期 + * @param {Object} stats - 高位股统计数据 + */ + const trackHighPositionStocksViewed = useCallback((date, stats = {}) => { + track(RETENTION_EVENTS.LIMIT_ANALYSE_PAGE_VIEWED, { + date, + view_type: 'high_position_stocks', + total_count: stats.total_count || 0, + max_consecutive_days: stats.max_consecutive_days || 0, + }); + + logger.debug('useLimitAnalyseEvents', '📊 High Position Stocks Viewed', { + date, + stats, + }); + }, [track]); + + /** + * 追踪板块分析查看(分布图/关联图) + * @param {string} date - 日期 + * @param {string} analysisType - 分析类型(distribution/relation/wordcloud) + */ + const trackSectorAnalysisViewed = useCallback((date, analysisType) => { + track(RETENTION_EVENTS.LIMIT_SECTOR_ANALYSIS_VIEWED, { + date, + analysis_type: analysisType, + source: 'limit_analyse', + }); + + logger.debug('useLimitAnalyseEvents', '📊 Sector Analysis Viewed', { + date, + analysisType, + }); + }, [track]); + + /** + * 追踪数据刷新 + * @param {string} date - 刷新的日期 + */ + const trackDataRefreshed = useCallback((date) => { + track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, { + filter_type: 'refresh', + filter_value: date, + context: 'limit_analyse', + }); + + logger.debug('useLimitAnalyseEvents', '🔄 Data Refreshed', { date }); + }, [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: 'limit_analyse_modal', + }); + + logger.debug('useLimitAnalyseEvents', '👁️ Stock Detail Modal Opened', { + stockCode, + stockName, + }); + }, [track]); + + return { + trackDateSelected, + trackDailyStatsViewed, + trackSectorToggled, + trackSectorClicked, + trackLimitStockClicked, + trackSearchInitiated, + trackSearchResultClicked, + trackHighPositionStocksViewed, + trackSectorAnalysisViewed, + trackDataRefreshed, + trackStockDetailViewed, + }; +}; diff --git a/src/views/LimitAnalyse/index.js b/src/views/LimitAnalyse/index.js index 8278dde9..0be7d766 100755 --- a/src/views/LimitAnalyse/index.js +++ b/src/views/LimitAnalyse/index.js @@ -48,6 +48,7 @@ import { AdvancedSearch, SearchResultsModal } from './components/SearchComponent // 导入高位股统计组件 import HighPositionStocks from './components/HighPositionStocks'; import { logger } from '../../utils/logger'; +import { useLimitAnalyseEvents } from './hooks/useLimitAnalyseEvents'; // 主组件 export default function LimitAnalyse() { @@ -59,9 +60,26 @@ export default function LimitAnalyse() { const [wordCloudData, setWordCloudData] = useState([]); const [searchResults, setSearchResults] = useState(null); const [isSearchOpen, setIsSearchOpen] = useState(false); + const [selectedStock, setSelectedStock] = useState(null); + const [isStockDetailOpen, setIsStockDetailOpen] = useState(false); const toast = useToast(); + // 🎯 PostHog 事件追踪 + const { + trackDateSelected, + trackDailyStatsViewed, + trackSectorToggled, + trackSectorClicked, + trackLimitStockClicked, + trackSearchInitiated, + trackSearchResultClicked, + trackHighPositionStocksViewed, + trackSectorAnalysisViewed, + trackDataRefreshed, + trackStockDetailViewed, + } = useLimitAnalyseEvents(); + const bgColor = useColorModeValue('gray.50', 'gray.900'); const cardBg = useColorModeValue('white', 'gray.800'); const accentColor = useColorModeValue('blue.500', 'blue.300'); @@ -126,6 +144,9 @@ export default function LimitAnalyse() { if (data.success) { setDailyData(data.data); + // 🎯 追踪每日统计数据查看 + trackDailyStatsViewed(data.data, date); + // 获取词云数据 fetchWordCloudData(date); @@ -169,14 +190,26 @@ export default function LimitAnalyse() { // 处理日期选择 const handleDateChange = (date) => { + const previousDateStr = dateStr; setSelectedDate(date); const dateString = formatDateStr(date); setDateStr(dateString); + + // 🎯 追踪日期选择 + trackDateSelected(dateString, previousDateStr); + fetchDailyAnalysis(dateString); }; // 处理搜索 const handleSearch = async (searchParams) => { + // 🎯 追踪搜索开始 + trackSearchInitiated( + searchParams.query, + searchParams.type || 'all', + searchParams.mode || 'hybrid' + ); + setLoading(true); try { const response = await fetch(`${API_URL}/api/v1/stocks/search/hybrid`, { @@ -212,6 +245,20 @@ export default function LimitAnalyse() { } }; + // 处理股票点击 + const handleStockClick = (stock) => { + setSelectedStock(stock); + setIsStockDetailOpen(true); + // 🎯 追踪股票详情查看 + trackStockDetailViewed(stock.scode, stock.sname, 'sector_details'); + }; + + // 关闭股票详情弹窗 + const handleCloseStockDetail = () => { + setIsStockDetailOpen(false); + setSelectedStock(null); + }; + // 处理板块数据排序 const getSortedSectorData = () => { if (!dailyData?.sector_data) return []; @@ -439,6 +486,7 @@ export default function LimitAnalyse() { )} @@ -465,6 +513,13 @@ export default function LimitAnalyse() { onStockClick={() => {}} /> + {/* 股票详情弹窗 */} + + {/* 浮动按钮 */} diff --git a/src/views/Profile/ProfilePage.js b/src/views/Profile/ProfilePage.js index b5099c65..fd2d155e 100644 --- a/src/views/Profile/ProfilePage.js +++ b/src/views/Profile/ProfilePage.js @@ -44,11 +44,15 @@ import { import { EditIcon, CheckIcon, CloseIcon, AddIcon } from '@chakra-ui/icons'; import { useAuth } from '../../contexts/AuthContext'; import { logger } from '../../utils/logger'; +import { useProfileEvents } from '../../hooks/useProfileEvents'; export default function ProfilePage() { const { user, updateUser } = useAuth(); const [isEditing, setIsEditing] = useState(false); const [isLoading, setIsLoading] = useState(false); + + // 🎯 初始化个人资料埋点Hook + const profileEvents = useProfileEvents({ pageType: 'profile' }); const [newTag, setNewTag] = useState(''); const { isOpen, onOpen, onClose } = useDisclosure(); const fileInputRef = useRef(); @@ -95,6 +99,12 @@ export default function ProfilePage() { updateUser(updatedData); setIsEditing(false); + // 🎯 追踪个人资料更新成功 + const updatedFields = Object.keys(formData).filter( + key => user?.[key] !== formData[key] + ); + profileEvents.trackProfileUpdated(updatedFields, updatedData); + // ✅ 保留关键操作提示 toast({ title: "个人资料更新成功", @@ -105,6 +115,10 @@ export default function ProfilePage() { } catch (error) { logger.error('ProfilePage', 'handleSaveProfile', error, { userId: user?.id }); + // 🎯 追踪个人资料更新失败 + const attemptedFields = Object.keys(formData); + profileEvents.trackProfileUpdateFailed(attemptedFields, error.message); + // ✅ 保留错误提示 toast({ title: "更新失败", @@ -128,6 +142,9 @@ export default function ProfilePage() { reader.onload = (e) => { updateUser({ avatar_url: e.target.result }); + // 🎯 追踪头像上传 + profileEvents.trackAvatarUploaded('file_upload', file.size); + // ✅ 保留关键操作提示 toast({ title: "头像更新成功", diff --git a/src/views/Settings/SettingsPage.js b/src/views/Settings/SettingsPage.js index c855b8fc..d592d316 100644 --- a/src/views/Settings/SettingsPage.js +++ b/src/views/Settings/SettingsPage.js @@ -59,12 +59,16 @@ import { FaWeixin, FaMobile, FaEnvelope } from 'react-icons/fa'; import { useAuth } from '../../contexts/AuthContext'; import { getApiBase } from '../../utils/apiConfig'; import { logger } from '../../utils/logger'; +import { useProfileEvents } from '../../hooks/useProfileEvents'; export default function SettingsPage() { const { user, updateUser, logout } = useAuth(); const { colorMode, toggleColorMode } = useColorMode(); const toast = useToast(); + // 🎯 初始化设置页面埋点Hook + const profileEvents = useProfileEvents({ pageType: 'settings' }); + // 模态框状态 const { isOpen: isPasswordOpen, onOpen: onPasswordOpen, onClose: onPasswordClose } = useDisclosure(); const { isOpen: isPhoneOpen, onOpen: onPhoneOpen, onClose: onPhoneClose } = useDisclosure(); @@ -209,9 +213,12 @@ export default function SettingsPage() { if (response.ok && data.success) { const isFirstSet = passwordStatus.needsFirstTimeSetup; - + + // 🎯 追踪密码修改成功 + profileEvents.trackPasswordChanged(true); + toast({ - title: isFirstSet ? "密码设置成功" : "密码修改成功", + title: isFirstSet ? "密码设置成功" : "密码修改成功", description: isFirstSet ? "您现在可以使用手机号+密码登录了" : "请重新登录", status: "success", duration: 3000, @@ -220,7 +227,7 @@ export default function SettingsPage() { setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); onPasswordClose(); - + // 刷新密码状态 fetchPasswordStatus(); @@ -234,6 +241,9 @@ export default function SettingsPage() { throw new Error(data.error || '密码修改失败'); } } catch (error) { + // 🎯 追踪密码修改失败 + profileEvents.trackPasswordChanged(false, error.message); + toast({ title: "修改失败", description: error.message, @@ -364,6 +374,9 @@ export default function SettingsPage() { email_confirmed: data.user.email_confirmed }); + // 🎯 追踪邮箱绑定成功 + profileEvents.trackAccountBound('email', true); + toast({ title: "邮箱绑定成功", status: "success", @@ -374,6 +387,9 @@ export default function SettingsPage() { setEmailForm({ email: '', verificationCode: '' }); onEmailClose(); } catch (error) { + // 🎯 追踪邮箱绑定失败 + profileEvents.trackAccountBound('email', false); + toast({ title: "绑定失败", description: error.message, @@ -397,6 +413,13 @@ export default function SettingsPage() { updateUser(notifications); + // 🎯 追踪通知偏好更改 + profileEvents.trackNotificationPreferencesChanged({ + email: notifications.email_notifications, + push: notifications.system_updates, + sms: notifications.sms_notifications + }); + // ❌ 移除设置保存成功toast logger.info('SettingsPage', '通知设置已保存'); } catch (error) { diff --git a/src/views/StockOverview/hooks/useStockOverviewEvents.js b/src/views/StockOverview/hooks/useStockOverviewEvents.js new file mode 100644 index 00000000..871f8a5b --- /dev/null +++ b/src/views/StockOverview/hooks/useStockOverviewEvents.js @@ -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, + }; +}; diff --git a/src/views/StockOverview/index.js b/src/views/StockOverview/index.js index f2a968e7..0237059e 100644 --- a/src/views/StockOverview/index.js +++ b/src/views/StockOverview/index.js @@ -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={} onClick={(e) => { e.stopPropagation(); - handleConceptClick(concept.concept_id, concept.concept_name); + handleConceptClick(concept, index); }} > 查看详情 diff --git a/src/views/TradingSimulation/components/AccountOverview.js b/src/views/TradingSimulation/components/AccountOverview.js index 49fd2fc7..c677dc44 100644 --- a/src/views/TradingSimulation/components/AccountOverview.js +++ b/src/views/TradingSimulation/components/AccountOverview.js @@ -28,7 +28,9 @@ import { FiTrendingUp, FiTrendingDown, FiDollarSign, FiPieChart, FiTarget, FiAct import DonutChart from '../../../components/Charts/DonutChart'; import IconBox from '../../../components/Icons/IconBox'; -export default function AccountOverview({ account }) { +export default function AccountOverview({ account, tradingEvents }) { + // tradingEvents 已传递,可用于将来添加的账户重置等功能 + // 例如: tradingEvents.trackAccountReset(beforeResetData) const textColor = useColorModeValue('gray.700', 'white'); const secondaryColor = useColorModeValue('gray.500', 'gray.400'); const profitColor = account?.totalProfit >= 0 ? 'green.500' : 'red.500'; diff --git a/src/views/TradingSimulation/components/PositionsList.js b/src/views/TradingSimulation/components/PositionsList.js index 8d27673f..cca36095 100644 --- a/src/views/TradingSimulation/components/PositionsList.js +++ b/src/views/TradingSimulation/components/PositionsList.js @@ -64,20 +64,38 @@ const calculateChange = (currentPrice, avgPrice) => { return { change, changePercent }; }; -export default function PositionsList({ positions, account, onSellStock }) { +export default function PositionsList({ positions, account, onSellStock, tradingEvents }) { const [selectedPosition, setSelectedPosition] = useState(null); const [sellQuantity, setSellQuantity] = useState(0); const [orderType, setOrderType] = useState('MARKET'); const [limitPrice, setLimitPrice] = useState(''); const [isLoading, setIsLoading] = useState(false); - + const [hasTracked, setHasTracked] = React.useState(false); + const { isOpen, onOpen, onClose } = useDisclosure(); const toast = useToast(); - + const cardBg = useColorModeValue('white', 'gray.800'); const textColor = useColorModeValue('gray.700', 'white'); const secondaryColor = useColorModeValue('gray.500', 'gray.400'); + // 🎯 追踪持仓查看 - 组件加载时触发一次 + React.useEffect(() => { + if (!hasTracked && positions && positions.length > 0 && tradingEvents && tradingEvents.trackSimulationHoldingsViewed) { + const totalMarketValue = positions.reduce((sum, pos) => sum + (pos.marketValue || pos.quantity * pos.currentPrice || 0), 0); + const totalCost = positions.reduce((sum, pos) => sum + (pos.totalCost || pos.quantity * pos.avgPrice || 0), 0); + const totalProfit = positions.reduce((sum, pos) => sum + (pos.profit || 0), 0); + + tradingEvents.trackSimulationHoldingsViewed({ + count: positions.length, + totalValue: totalMarketValue, + totalCost, + profitLoss: totalProfit, + }); + setHasTracked(true); + } + }, [positions, tradingEvents, hasTracked]); + // 格式化货币 const formatCurrency = (amount) => { return new Intl.NumberFormat('zh-CN', { @@ -102,6 +120,17 @@ export default function PositionsList({ positions, account, onSellStock }) { setSelectedPosition(position); setSellQuantity(position.availableQuantity); // 默认全部可卖数量 setLimitPrice(position.currentPrice?.toString() || position.avgPrice.toString()); + + // 🎯 追踪卖出按钮点击 + if (tradingEvents && tradingEvents.trackSellButtonClicked) { + tradingEvents.trackSellButtonClicked({ + stockCode: position.stockCode, + stockName: position.stockName, + quantity: position.quantity, + profitLoss: position.profit || 0, + }, 'holdings'); + } + onOpen(); }; @@ -110,6 +139,8 @@ export default function PositionsList({ positions, account, onSellStock }) { if (!selectedPosition || sellQuantity <= 0) return; setIsLoading(true); + const price = orderType === 'LIMIT' ? parseFloat(limitPrice) : selectedPosition.currentPrice || selectedPosition.avgPrice; + try { const result = await onSellStock( selectedPosition.stockCode, @@ -126,6 +157,20 @@ export default function PositionsList({ positions, account, onSellStock }) { orderType, orderId: result.orderId }); + + // 🎯 追踪卖出成功 + if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) { + tradingEvents.trackSimulationOrderPlaced({ + stockCode: selectedPosition.stockCode, + stockName: selectedPosition.stockName, + direction: 'sell', + quantity: sellQuantity, + price, + orderType, + success: true, + }); + } + toast({ title: '卖出成功', description: `已卖出 ${selectedPosition.stockName} ${sellQuantity} 股`, @@ -142,6 +187,21 @@ export default function PositionsList({ positions, account, onSellStock }) { quantity: sellQuantity, orderType }); + + // 🎯 追踪卖出失败 + if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) { + tradingEvents.trackSimulationOrderPlaced({ + stockCode: selectedPosition.stockCode, + stockName: selectedPosition.stockName, + direction: 'sell', + quantity: sellQuantity, + price, + orderType, + success: false, + errorMessage: error.message, + }); + } + toast({ title: '卖出失败', description: error.message, diff --git a/src/views/TradingSimulation/components/TradingHistory.js b/src/views/TradingSimulation/components/TradingHistory.js index af1a9716..31797bd2 100644 --- a/src/views/TradingSimulation/components/TradingHistory.js +++ b/src/views/TradingSimulation/components/TradingHistory.js @@ -34,18 +34,31 @@ import { import { FiSearch, FiFilter, FiClock, FiTrendingUp, FiTrendingDown } from 'react-icons/fi'; import { logger } from '../../../utils/logger'; -export default function TradingHistory({ history, onCancelOrder }) { +export default function TradingHistory({ history, onCancelOrder, tradingEvents }) { const [filterType, setFilterType] = useState('ALL'); // ALL, BUY, SELL const [filterStatus, setFilterStatus] = useState('ALL'); // ALL, FILLED, PENDING, CANCELLED const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState('createdAt'); // createdAt, stockCode, amount const [sortOrder, setSortOrder] = useState('desc'); // desc, asc - + const [hasTracked, setHasTracked] = React.useState(false); + const toast = useToast(); const cardBg = useColorModeValue('white', 'gray.800'); const textColor = useColorModeValue('gray.700', 'white'); const secondaryColor = useColorModeValue('gray.500', 'gray.400'); + // 🎯 追踪历史记录查看 - 组件加载时触发一次 + React.useEffect(() => { + if (!hasTracked && history && history.length > 0 && tradingEvents && tradingEvents.trackSimulationHistoryViewed) { + tradingEvents.trackSimulationHistoryViewed({ + count: history.length, + filterBy: 'all', + dateRange: 'all', + }); + setHasTracked(true); + } + }, [history, tradingEvents, hasTracked]); + // 格式化货币 const formatCurrency = (amount) => { return new Intl.NumberFormat('zh-CN', { diff --git a/src/views/TradingSimulation/components/TradingPanel.js b/src/views/TradingSimulation/components/TradingPanel.js index f5f697e0..77cb2d87 100644 --- a/src/views/TradingSimulation/components/TradingPanel.js +++ b/src/views/TradingSimulation/components/TradingPanel.js @@ -55,7 +55,7 @@ import { FiSearch, FiTrendingUp, FiTrendingDown, FiDollarSign, FiZap, FiTarget } // 导入现有的高质量组件 import IconBox from '../../../components/Icons/IconBox'; -export default function TradingPanel({ account, onBuyStock, onSellStock, searchStocks }) { +export default function TradingPanel({ account, onBuyStock, onSellStock, searchStocks, tradingEvents }) { const [activeTab, setActiveTab] = useState(0); // 0: 买入, 1: 卖出 const [searchTerm, setSearchTerm] = useState(''); const [selectedStock, setSelectedStock] = useState(null); @@ -87,7 +87,7 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS const results = await searchStocks(searchTerm); // 转换为组件需要的格式 const formattedResults = results.map(stock => [ - stock.stock_code, + stock.stock_code, { name: stock.stock_name, price: stock.current_price || 0, // 使用后端返回的真实价格 @@ -97,10 +97,20 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS ]); setFilteredStocks(formattedResults); setShowStockList(true); + + // 🎯 追踪股票搜索 + if (tradingEvents && tradingEvents.trackSimulationStockSearched) { + tradingEvents.trackSimulationStockSearched(searchTerm, formattedResults.length); + } } catch (error) { logger.error('TradingPanel', 'handleStockSearch', error, { searchTerm }); setFilteredStocks([]); setShowStockList(false); + + // 🎯 追踪搜索无结果 + if (tradingEvents && tradingEvents.trackSimulationStockSearched) { + tradingEvents.trackSimulationStockSearched(searchTerm, 0); + } } } else { setFilteredStocks([]); @@ -109,7 +119,7 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS }, 300); // 300ms 防抖 return () => clearTimeout(searchDebounced); - }, [searchTerm, searchStocks]); + }, [searchTerm, searchStocks, tradingEvents]); // 选择股票 const handleSelectStock = (code, stock) => { @@ -169,6 +179,9 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS if (!validateForm()) return; setIsLoading(true); + const price = orderType === 'LIMIT' ? parseFloat(limitPrice) : selectedStock.price; + const direction = activeTab === 0 ? 'buy' : 'sell'; + try { let result; if (activeTab === 0) { @@ -197,6 +210,19 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS orderType }); + // 🎯 追踪下单成功 + if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) { + tradingEvents.trackSimulationOrderPlaced({ + stockCode: selectedStock.code, + stockName: selectedStock.name, + direction, + quantity, + price, + orderType, + success: true, + }); + } + // ✅ 保留交易成功toast(关键用户操作反馈) toast({ title: activeTab === 0 ? '买入成功' : '卖出成功', @@ -217,6 +243,20 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS orderType }); + // 🎯 追踪下单失败 + if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) { + tradingEvents.trackSimulationOrderPlaced({ + stockCode: selectedStock.code, + stockName: selectedStock.name, + direction, + quantity, + price, + orderType, + success: false, + errorMessage: error.message, + }); + } + // ✅ 保留交易失败toast(关键用户操作错误反馈) toast({ title: activeTab === 0 ? '买入失败' : '卖出失败', diff --git a/src/views/TradingSimulation/hooks/useTradingSimulationEvents.js b/src/views/TradingSimulation/hooks/useTradingSimulationEvents.js new file mode 100644 index 00000000..f849f019 --- /dev/null +++ b/src/views/TradingSimulation/hooks/useTradingSimulationEvents.js @@ -0,0 +1,303 @@ +// src/views/TradingSimulation/hooks/useTradingSimulationEvents.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 {Object} options.portfolio - 账户信息 + * @param {number} options.portfolio.totalValue - 总资产 + * @param {number} options.portfolio.availableCash - 可用资金 + * @param {number} options.portfolio.holdingsCount - 持仓数量 + * @param {Function} options.navigate - 路由导航函数 + * @returns {Object} 事件追踪处理函数集合 + */ +export const useTradingSimulationEvents = ({ portfolio, navigate } = {}) => { + const { track } = usePostHogTrack(); + + // 🎯 页面浏览事件 - 页面加载时触发 + useEffect(() => { + track(RETENTION_EVENTS.TRADING_SIMULATION_ENTERED, { + total_value: portfolio?.totalValue || 0, + available_cash: portfolio?.availableCash || 0, + holdings_count: portfolio?.holdingsCount || 0, + has_holdings: Boolean(portfolio?.holdingsCount && portfolio.holdingsCount > 0), + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '🎮 Trading Simulation Entered', { + totalValue: portfolio?.totalValue, + holdingsCount: portfolio?.holdingsCount, + }); + }, [track, portfolio]); + + /** + * 追踪股票搜索(模拟盘内) + * @param {string} query - 搜索关键词 + * @param {number} resultCount - 搜索结果数量 + */ + const trackSimulationStockSearched = useCallback((query, resultCount = 0) => { + if (!query) return; + + track(RETENTION_EVENTS.SIMULATION_STOCK_SEARCHED, { + query, + result_count: resultCount, + has_results: resultCount > 0, + timestamp: new Date().toISOString(), + }); + + // 如果没有搜索结果,额外追踪 + if (resultCount === 0) { + track(RETENTION_EVENTS.SEARCH_NO_RESULTS, { + query, + context: 'trading_simulation', + timestamp: new Date().toISOString(), + }); + } + + logger.debug('useTradingSimulationEvents', '🔍 Simulation Stock Searched', { + query, + resultCount, + }); + }, [track]); + + /** + * 追踪下单操作 + * @param {Object} order - 订单信息 + * @param {string} order.stockCode - 股票代码 + * @param {string} order.stockName - 股票名称 + * @param {string} order.direction - 买卖方向 ('buy' | 'sell') + * @param {number} order.quantity - 数量 + * @param {number} order.price - 价格 + * @param {string} order.orderType - 订单类型 ('market' | 'limit') + * @param {boolean} order.success - 是否成功 + */ + const trackSimulationOrderPlaced = useCallback((order) => { + if (!order || !order.stockCode) { + logger.warn('useTradingSimulationEvents', 'Order object is required'); + return; + } + + track(RETENTION_EVENTS.SIMULATION_ORDER_PLACED, { + stock_code: order.stockCode, + stock_name: order.stockName || '', + direction: order.direction, + quantity: order.quantity, + price: order.price, + order_type: order.orderType || 'market', + order_value: order.quantity * order.price, + success: order.success, + error_message: order.errorMessage || null, + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '📝 Simulation Order Placed', { + stockCode: order.stockCode, + direction: order.direction, + quantity: order.quantity, + success: order.success, + }); + }, [track]); + + /** + * 追踪持仓查看 + * @param {Object} holdings - 持仓信息 + * @param {number} holdings.count - 持仓数量 + * @param {number} holdings.totalValue - 持仓总市值 + * @param {number} holdings.totalCost - 持仓总成本 + * @param {number} holdings.profitLoss - 总盈亏 + */ + const trackSimulationHoldingsViewed = useCallback((holdings = {}) => { + track(RETENTION_EVENTS.SIMULATION_HOLDINGS_VIEWED, { + holdings_count: holdings.count || 0, + total_value: holdings.totalValue || 0, + total_cost: holdings.totalCost || 0, + profit_loss: holdings.profitLoss || 0, + profit_loss_percent: holdings.totalCost ? ((holdings.profitLoss / holdings.totalCost) * 100).toFixed(2) : 0, + has_profit: holdings.profitLoss > 0, + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '💼 Simulation Holdings Viewed', { + count: holdings.count, + profitLoss: holdings.profitLoss, + }); + }, [track]); + + /** + * 追踪持仓股票点击 + * @param {Object} holding - 持仓对象 + * @param {string} holding.stockCode - 股票代码 + * @param {string} holding.stockName - 股票名称 + * @param {number} holding.profitLoss - 盈亏金额 + * @param {number} position - 在列表中的位置 + */ + const trackHoldingClicked = useCallback((holding, position = 0) => { + if (!holding || !holding.stockCode) { + logger.warn('useTradingSimulationEvents', 'Holding object is required'); + return; + } + + track(RETENTION_EVENTS.STOCK_CLICKED, { + stock_code: holding.stockCode, + stock_name: holding.stockName || '', + source: 'simulation_holdings', + profit_loss: holding.profitLoss || 0, + position, + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '🎯 Holding Clicked', { + stockCode: holding.stockCode, + position, + }); + }, [track]); + + /** + * 追踪历史交易记录查看 + * @param {Object} history - 历史记录信息 + * @param {number} history.count - 交易记录数量 + * @param {string} history.filterBy - 筛选条件 ('all' | 'buy' | 'sell') + * @param {string} history.dateRange - 日期范围 + */ + const trackSimulationHistoryViewed = useCallback((history = {}) => { + track(RETENTION_EVENTS.SIMULATION_HISTORY_VIEWED, { + history_count: history.count || 0, + filter_by: history.filterBy || 'all', + date_range: history.dateRange || 'all', + has_history: Boolean(history.count && history.count > 0), + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '📜 Simulation History Viewed', { + count: history.count, + filterBy: history.filterBy, + }); + }, [track]); + + /** + * 追踪买入按钮点击 + * @param {Object} stock - 股票对象 + * @param {string} stock.code - 股票代码 + * @param {string} stock.name - 股票名称 + * @param {number} stock.price - 当前价格 + * @param {string} source - 来源 ('search' | 'holdings' | 'stock_detail') + */ + const trackBuyButtonClicked = useCallback((stock, source = 'search') => { + if (!stock || !stock.code) { + logger.warn('useTradingSimulationEvents', 'Stock object is required'); + return; + } + + track('Simulation Buy Button Clicked', { + stock_code: stock.code, + stock_name: stock.name || '', + current_price: stock.price || 0, + source, + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '🟢 Buy Button Clicked', { + stockCode: stock.code, + source, + }); + }, [track]); + + /** + * 追踪卖出按钮点击 + * @param {Object} holding - 持仓对象 + * @param {string} holding.stockCode - 股票代码 + * @param {string} holding.stockName - 股票名称 + * @param {number} holding.quantity - 持有数量 + * @param {number} holding.profitLoss - 盈亏金额 + * @param {string} source - 来源 ('holdings' | 'stock_detail') + */ + const trackSellButtonClicked = useCallback((holding, source = 'holdings') => { + if (!holding || !holding.stockCode) { + logger.warn('useTradingSimulationEvents', 'Holding object is required'); + return; + } + + track('Simulation Sell Button Clicked', { + stock_code: holding.stockCode, + stock_name: holding.stockName || '', + quantity: holding.quantity || 0, + profit_loss: holding.profitLoss || 0, + source, + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '🔴 Sell Button Clicked', { + stockCode: holding.stockCode, + source, + }); + }, [track]); + + /** + * 追踪账户重置 + * @param {Object} beforeReset - 重置前的账户信息 + * @param {number} beforeReset.totalValue - 总资产 + * @param {number} beforeReset.profitLoss - 总盈亏 + */ + const trackAccountReset = useCallback((beforeReset = {}) => { + track('Simulation Account Reset', { + total_value_before: beforeReset.totalValue || 0, + profit_loss_before: beforeReset.profitLoss || 0, + holdings_count_before: beforeReset.holdingsCount || 0, + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '🔄 Account Reset', { + totalValueBefore: beforeReset.totalValue, + }); + }, [track]); + + /** + * 追踪标签页切换 + * @param {string} tabName - 标签名称 ('trading' | 'holdings' | 'history') + */ + const trackTabClicked = useCallback((tabName) => { + if (!tabName) { + logger.warn('useTradingSimulationEvents', 'Tab name is required'); + return; + } + + track('Simulation Tab Clicked', { + tab_name: tabName, + timestamp: new Date().toISOString(), + }); + + logger.debug('useTradingSimulationEvents', '📑 Tab Clicked', { + tabName, + }); + }, [track]); + + return { + // 搜索事件 + trackSimulationStockSearched, + + // 交易事件 + trackSimulationOrderPlaced, + trackBuyButtonClicked, + trackSellButtonClicked, + + // 持仓事件 + trackSimulationHoldingsViewed, + trackHoldingClicked, + + // 历史记录事件 + trackSimulationHistoryViewed, + + // 账户管理事件 + trackAccountReset, + + // UI交互事件 + trackTabClicked, + }; +}; + +export default useTradingSimulationEvents; diff --git a/src/views/TradingSimulation/index.js b/src/views/TradingSimulation/index.js index 22058ab9..31624c59 100644 --- a/src/views/TradingSimulation/index.js +++ b/src/views/TradingSimulation/index.js @@ -49,6 +49,7 @@ import LineChart from '../../components/Charts/LineChart'; // 模拟盘账户管理 Hook import { useTradingAccount } from './hooks/useTradingAccount'; +import { useTradingSimulationEvents } from './hooks/useTradingSimulationEvents'; export default function TradingSimulation() { // ========== 1. 所有 Hooks 必须放在最顶部,不能有任何条件判断 ========== @@ -76,6 +77,15 @@ export default function TradingSimulation() { getAssetHistory } = useTradingAccount(); + // 🎯 初始化模拟盘埋点Hook(传入账户信息) + const tradingEvents = useTradingSimulationEvents({ + portfolio: account ? { + totalValue: account.total_assets, + availableCash: account.available_cash, + holdingsCount: positions?.length || 0 + } : null + }); + // 所有的 useColorModeValue 也必须在顶部 const bgColor = useColorModeValue('gray.50', 'gray.900'); const cardBg = useColorModeValue('white', 'gray.800'); @@ -271,9 +281,14 @@ export default function TradingSimulation() { {/* 主要功能区域 - 放在上面 */} - { + setActiveTab(index); + // 🎯 追踪 Tab 切换 + const tabNames = ['trading', 'holdings', 'history', 'margin']; + tradingEvents.trackTabClicked(tabNames[index]); + }} variant="soft-rounded" colorScheme="blue" size="lg" @@ -288,28 +303,31 @@ export default function TradingSimulation() { {/* 交易面板 */} - {/* 我的持仓 */} - {/* 交易历史 */} - @@ -331,7 +349,7 @@ export default function TradingSimulation() { 📊 账户统计分析 - + {/* 资产走势图表 - 只在有数据时显示 */}
当前用户: {user.userId}