feat: 从 React Context 迁移到 Redux,实现了:
1. ✅ 集中式状态管理 - PostHog 状态与应用状态统一管理 2. ✅ 自动追踪机制 - Middleware 自动拦截 Redux actions 进行追踪 3. ✅ Redux DevTools 支持 - 可视化调试所有 PostHog 事件 4. ✅ 离线事件缓存 - 网络恢复时自动刷新缓存事件 5. ✅ 性能优化 Hooks - 提供轻量级 Hook 避免不必要的重渲染
This commit is contained in:
68
src/App.js
68
src/App.js
@@ -59,9 +59,12 @@ import NotificationContainer from "components/NotificationContainer";
|
|||||||
import ConnectionStatusBar from "components/ConnectionStatusBar";
|
import ConnectionStatusBar from "components/ConnectionStatusBar";
|
||||||
import NotificationTestTool from "components/NotificationTestTool";
|
import NotificationTestTool from "components/NotificationTestTool";
|
||||||
import ScrollToTop from "components/ScrollToTop";
|
import ScrollToTop from "components/ScrollToTop";
|
||||||
import PostHogProvider from "components/PostHogProvider";
|
|
||||||
import { logger } from "utils/logger";
|
import { logger } from "utils/logger";
|
||||||
|
|
||||||
|
// PostHog Redux 集成
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { initializePostHog } from "store/slices/posthogSlice";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ConnectionStatusBar 包装组件
|
* ConnectionStatusBar 包装组件
|
||||||
* 需要在 NotificationProvider 内部使用,所以单独提取
|
* 需要在 NotificationProvider 内部使用,所以单独提取
|
||||||
@@ -109,6 +112,13 @@ function ConnectionStatusBarWrapper() {
|
|||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { colorMode } = useColorMode();
|
const { colorMode } = useColorMode();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// 🎯 PostHog Redux 初始化
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(initializePostHog());
|
||||||
|
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||||
@@ -296,34 +306,32 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PostHogProvider>
|
<ReduxProvider store={store}>
|
||||||
<ReduxProvider store={store}>
|
<ChakraProvider
|
||||||
<ChakraProvider
|
theme={theme}
|
||||||
theme={theme}
|
toastOptions={{
|
||||||
toastOptions={{
|
defaultOptions: {
|
||||||
defaultOptions: {
|
position: 'top',
|
||||||
position: 'top',
|
duration: 3000,
|
||||||
duration: 3000,
|
isClosable: true,
|
||||||
isClosable: true,
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<NotificationProvider>
|
||||||
<NotificationProvider>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<AuthModalProvider>
|
||||||
<AuthModalProvider>
|
<IndustryProvider>
|
||||||
<IndustryProvider>
|
<AppContent />
|
||||||
<AppContent />
|
<AuthModalManager />
|
||||||
<AuthModalManager />
|
<NotificationContainer />
|
||||||
<NotificationContainer />
|
<NotificationTestTool />
|
||||||
<NotificationTestTool />
|
</IndustryProvider>
|
||||||
</IndustryProvider>
|
</AuthModalProvider>
|
||||||
</AuthModalProvider>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
</NotificationProvider>
|
||||||
</NotificationProvider>
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</ChakraProvider>
|
||||||
</ChakraProvider>
|
</ReduxProvider>
|
||||||
</ReduxProvider>
|
|
||||||
</PostHogProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
272
src/hooks/usePostHogRedux.js
Normal file
272
src/hooks/usePostHogRedux.js
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
// src/hooks/usePostHogRedux.js
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import {
|
||||||
|
trackEvent,
|
||||||
|
identifyUser,
|
||||||
|
resetUser,
|
||||||
|
optIn,
|
||||||
|
optOut,
|
||||||
|
selectPostHog,
|
||||||
|
selectIsInitialized,
|
||||||
|
selectUser,
|
||||||
|
selectFeatureFlags,
|
||||||
|
selectFeatureFlag,
|
||||||
|
selectIsOptedOut,
|
||||||
|
selectStats,
|
||||||
|
flushCachedEvents,
|
||||||
|
} from '../store/slices/posthogSlice';
|
||||||
|
import { trackPageView } from '../lib/posthog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostHog Redux Hook
|
||||||
|
* 提供便捷的 PostHog 功能访问接口
|
||||||
|
*
|
||||||
|
* 用法示例:
|
||||||
|
*
|
||||||
|
* ```jsx
|
||||||
|
* import { usePostHogRedux } from 'hooks/usePostHogRedux';
|
||||||
|
* import { RETENTION_EVENTS } from 'lib/constants';
|
||||||
|
*
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { track, identify, user, isInitialized } = usePostHogRedux();
|
||||||
|
*
|
||||||
|
* const handleClick = () => {
|
||||||
|
* track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||||
|
* article_id: '123',
|
||||||
|
* article_title: '标题',
|
||||||
|
* });
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* if (!isInitialized) {
|
||||||
|
* return <div>正在加载...</div>;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* <button onClick={handleClick}>点击追踪</button>
|
||||||
|
* {user && <p>当前用户: {user.userId}</p>}
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const usePostHogRedux = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Selectors
|
||||||
|
const posthog = useSelector(selectPostHog);
|
||||||
|
const isInitialized = useSelector(selectIsInitialized);
|
||||||
|
const user = useSelector(selectUser);
|
||||||
|
const featureFlags = useSelector(selectFeatureFlags);
|
||||||
|
const stats = useSelector(selectStats);
|
||||||
|
|
||||||
|
// ==================== 追踪事件 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪自定义事件
|
||||||
|
* @param {string} eventName - 事件名称(建议使用 constants.js 中的常量)
|
||||||
|
* @param {object} properties - 事件属性
|
||||||
|
*/
|
||||||
|
const track = useCallback(
|
||||||
|
(eventName, properties = {}) => {
|
||||||
|
dispatch(trackEvent({ eventName, properties }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪页面浏览
|
||||||
|
* @param {string} pagePath - 页面路径
|
||||||
|
* @param {object} properties - 页面属性
|
||||||
|
*/
|
||||||
|
const trackPage = useCallback(
|
||||||
|
(pagePath, properties = {}) => {
|
||||||
|
trackPageView(pagePath, properties);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== 用户管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别用户(登录后调用)
|
||||||
|
* @param {string} userId - 用户 ID
|
||||||
|
* @param {object} userProperties - 用户属性
|
||||||
|
*/
|
||||||
|
const identify = useCallback(
|
||||||
|
(userId, userProperties = {}) => {
|
||||||
|
dispatch(identifyUser({ userId, userProperties }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置用户会话(登出时调用)
|
||||||
|
*/
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
dispatch(resetUser());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ==================== 隐私控制 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户选择退出追踪
|
||||||
|
*/
|
||||||
|
const optOutTracking = useCallback(() => {
|
||||||
|
dispatch(optOut());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户选择加入追踪
|
||||||
|
*/
|
||||||
|
const optInTracking = useCallback(() => {
|
||||||
|
dispatch(optIn());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已退出追踪
|
||||||
|
*/
|
||||||
|
const isOptedOut = selectIsOptedOut();
|
||||||
|
|
||||||
|
// ==================== Feature Flags ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取特定 Feature Flag 的值
|
||||||
|
* @param {string} flagKey - Flag 键名
|
||||||
|
* @returns {any} Flag 值
|
||||||
|
*/
|
||||||
|
const getFlag = useCallback(
|
||||||
|
(flagKey) => {
|
||||||
|
return selectFeatureFlag(flagKey)({ posthog });
|
||||||
|
},
|
||||||
|
[posthog]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Feature Flag 是否启用
|
||||||
|
* @param {string} flagKey - Flag 键名
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const isEnabled = useCallback(
|
||||||
|
(flagKey) => {
|
||||||
|
const value = getFlag(flagKey);
|
||||||
|
return Boolean(value);
|
||||||
|
},
|
||||||
|
[getFlag]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== 离线事件管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新缓存的离线事件
|
||||||
|
*/
|
||||||
|
const flushEvents = useCallback(() => {
|
||||||
|
dispatch(flushCachedEvents());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ==================== 返回接口 ====================
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
isInitialized,
|
||||||
|
user,
|
||||||
|
featureFlags,
|
||||||
|
stats,
|
||||||
|
posthog, // 完整的 PostHog 状态
|
||||||
|
|
||||||
|
// 追踪方法
|
||||||
|
track,
|
||||||
|
trackPage,
|
||||||
|
|
||||||
|
// 用户管理
|
||||||
|
identify,
|
||||||
|
reset,
|
||||||
|
|
||||||
|
// 隐私控制
|
||||||
|
optOut: optOutTracking,
|
||||||
|
optIn: optInTracking,
|
||||||
|
isOptedOut,
|
||||||
|
|
||||||
|
// Feature Flags
|
||||||
|
getFlag,
|
||||||
|
isEnabled,
|
||||||
|
|
||||||
|
// 离线事件
|
||||||
|
flushEvents,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 便捷 Hooks ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅获取追踪功能的 Hook(性能优化)
|
||||||
|
*/
|
||||||
|
export const usePostHogTrack = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const track = useCallback(
|
||||||
|
(eventName, properties = {}) => {
|
||||||
|
dispatch(trackEvent({ eventName, properties }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { track };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅获取 Feature Flags 的 Hook(性能优化)
|
||||||
|
*/
|
||||||
|
export const usePostHogFlags = () => {
|
||||||
|
const featureFlags = useSelector(selectFeatureFlags);
|
||||||
|
const posthog = useSelector(selectPostHog);
|
||||||
|
|
||||||
|
const getFlag = useCallback(
|
||||||
|
(flagKey) => {
|
||||||
|
return selectFeatureFlag(flagKey)({ posthog });
|
||||||
|
},
|
||||||
|
[posthog]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEnabled = useCallback(
|
||||||
|
(flagKey) => {
|
||||||
|
const value = getFlag(flagKey);
|
||||||
|
return Boolean(value);
|
||||||
|
},
|
||||||
|
[getFlag]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
featureFlags,
|
||||||
|
getFlag,
|
||||||
|
isEnabled,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息的 Hook(性能优化)
|
||||||
|
*/
|
||||||
|
export const usePostHogUser = () => {
|
||||||
|
const user = useSelector(selectUser);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const identify = useCallback(
|
||||||
|
(userId, userProperties = {}) => {
|
||||||
|
dispatch(identifyUser({ userId, userProperties }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
dispatch(resetUser());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
identify,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePostHogRedux;
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
// src/store/index.js
|
// src/store/index.js
|
||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import communityDataReducer from './slices/communityDataSlice';
|
import communityDataReducer from './slices/communityDataSlice';
|
||||||
|
import posthogReducer from './slices/posthogSlice';
|
||||||
|
import posthogMiddleware from './middleware/posthogMiddleware';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
communityData: communityDataReducer
|
communityData: communityDataReducer,
|
||||||
|
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
serializableCheck: {
|
serializableCheck: {
|
||||||
// 忽略这些 action types 的序列化检查
|
// 忽略这些 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;
|
export default store;
|
||||||
|
|||||||
281
src/store/middleware/posthogMiddleware.js
Normal file
281
src/store/middleware/posthogMiddleware.js
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
// src/store/middleware/posthogMiddleware.js
|
||||||
|
import { trackPageView } from '../../lib/posthog';
|
||||||
|
import { trackEvent } from '../slices/posthogSlice';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import {
|
||||||
|
ACTIVATION_EVENTS,
|
||||||
|
RETENTION_EVENTS,
|
||||||
|
SPECIAL_EVENTS,
|
||||||
|
REVENUE_EVENTS,
|
||||||
|
} from '../../lib/constants';
|
||||||
|
|
||||||
|
// ==================== 自动追踪规则配置 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action 到 PostHog 事件的映射
|
||||||
|
* 当这些 Redux actions 被 dispatch 时,自动追踪对应的 PostHog 事件
|
||||||
|
*/
|
||||||
|
const ACTION_TO_EVENT_MAP = {
|
||||||
|
// ==================== 登录/登出 ====================
|
||||||
|
'auth/login/fulfilled': {
|
||||||
|
event: ACTIVATION_EVENTS.USER_LOGGED_IN,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
login_method: action.payload?.login_method || 'unknown',
|
||||||
|
user_id: action.payload?.user?.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'auth/logout': {
|
||||||
|
event: SPECIAL_EVENTS.USER_LOGGED_OUT,
|
||||||
|
getProperties: () => ({}),
|
||||||
|
},
|
||||||
|
'auth/wechatLogin/fulfilled': {
|
||||||
|
event: ACTIVATION_EVENTS.USER_LOGGED_IN,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
login_method: 'wechat',
|
||||||
|
user_id: action.payload?.user?.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Community/新闻模块 ====================
|
||||||
|
'communityData/fetchHotEvents/fulfilled': {
|
||||||
|
event: RETENTION_EVENTS.NEWS_LIST_VIEWED,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
event_count: action.payload?.length || 0,
|
||||||
|
source: 'community_page',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'communityData/fetchPopularKeywords/fulfilled': {
|
||||||
|
event: RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED,
|
||||||
|
getProperties: () => ({
|
||||||
|
feature: 'popular_keywords',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 搜索 ====================
|
||||||
|
'search/submit': {
|
||||||
|
event: RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
query: action.payload?.query,
|
||||||
|
category: action.payload?.category,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'search/filterApplied': {
|
||||||
|
event: RETENTION_EVENTS.SEARCH_FILTER_APPLIED,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
filter_type: action.payload?.filterType,
|
||||||
|
filter_value: action.payload?.filterValue,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 支付/订阅 ====================
|
||||||
|
'payment/initiated': {
|
||||||
|
event: REVENUE_EVENTS.PAYMENT_INITIATED,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
amount: action.payload?.amount,
|
||||||
|
payment_method: action.payload?.method,
|
||||||
|
subscription_tier: action.payload?.tier,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'payment/success': {
|
||||||
|
event: REVENUE_EVENTS.PAYMENT_SUCCESSFUL,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
amount: action.payload?.amount,
|
||||||
|
transaction_id: action.payload?.transactionId,
|
||||||
|
subscription_tier: action.payload?.tier,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'subscription/upgraded': {
|
||||||
|
event: REVENUE_EVENTS.SUBSCRIPTION_UPGRADED,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
from_tier: action.payload?.fromTier,
|
||||||
|
to_tier: action.payload?.toTier,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 错误追踪 ====================
|
||||||
|
'error/occurred': {
|
||||||
|
event: SPECIAL_EVENTS.ERROR_OCCURRED,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
error_type: action.payload?.errorType,
|
||||||
|
error_message: action.payload?.message,
|
||||||
|
stack_trace: action.payload?.stack,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'api/error': {
|
||||||
|
event: SPECIAL_EVENTS.API_ERROR,
|
||||||
|
getProperties: (action) => ({
|
||||||
|
endpoint: action.payload?.endpoint,
|
||||||
|
status_code: action.payload?.statusCode,
|
||||||
|
error_message: action.payload?.message,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 页面路由追踪配置 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由变化的 action type(根据不同路由库调整)
|
||||||
|
*/
|
||||||
|
const LOCATION_CHANGE_ACTIONS = [
|
||||||
|
'@@router/LOCATION_CHANGE', // Redux-first router
|
||||||
|
'router/navigate', // 自定义路由 action
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据路径识别页面类型
|
||||||
|
*/
|
||||||
|
const getPageTypeFromPath = (pathname) => {
|
||||||
|
if (pathname === '/home' || pathname === '/') {
|
||||||
|
return { page_type: 'landing' };
|
||||||
|
} else if (pathname.startsWith('/auth/')) {
|
||||||
|
return { page_type: 'auth' };
|
||||||
|
} else if (pathname.startsWith('/community')) {
|
||||||
|
return { page_type: 'feature', feature_name: 'community' };
|
||||||
|
} else if (pathname.startsWith('/concepts')) {
|
||||||
|
return { page_type: 'feature', feature_name: 'concepts' };
|
||||||
|
} else if (pathname.startsWith('/stocks')) {
|
||||||
|
return { page_type: 'feature', feature_name: 'stocks' };
|
||||||
|
} else if (pathname.startsWith('/limit-analyse')) {
|
||||||
|
return { page_type: 'feature', feature_name: 'limit_analyse' };
|
||||||
|
} else if (pathname.startsWith('/trading-simulation')) {
|
||||||
|
return { page_type: 'feature', feature_name: 'trading_simulation' };
|
||||||
|
} else if (pathname.startsWith('/company')) {
|
||||||
|
return { page_type: 'detail', content_type: 'company' };
|
||||||
|
} else if (pathname.startsWith('/event-detail')) {
|
||||||
|
return { page_type: 'detail', content_type: 'event' };
|
||||||
|
}
|
||||||
|
return { page_type: 'other' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 中间件实现 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostHog Middleware
|
||||||
|
* 自动拦截 Redux actions 并追踪对应的 PostHog 事件
|
||||||
|
*/
|
||||||
|
const posthogMiddleware = (store) => (next) => (action) => {
|
||||||
|
// 先执行 action
|
||||||
|
const result = next(action);
|
||||||
|
|
||||||
|
// 获取当前 PostHog 状态
|
||||||
|
const state = store.getState();
|
||||||
|
const posthogState = state.posthog;
|
||||||
|
|
||||||
|
// 如果 PostHog 未初始化,不追踪(事件会被缓存到 eventQueue)
|
||||||
|
if (!posthogState?.isInitialized) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ==================== 1. 自动追踪特定 actions ====================
|
||||||
|
if (ACTION_TO_EVENT_MAP[action.type]) {
|
||||||
|
const { event, getProperties } = ACTION_TO_EVENT_MAP[action.type];
|
||||||
|
const properties = getProperties(action);
|
||||||
|
|
||||||
|
// 通过 dispatch 追踪事件(会走 Redux 状态管理)
|
||||||
|
store.dispatch(trackEvent({ eventName: event, properties }));
|
||||||
|
|
||||||
|
logger.debug('PostHog Middleware', `自动追踪事件: ${event}`, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 2. 路由变化追踪 ====================
|
||||||
|
if (LOCATION_CHANGE_ACTIONS.includes(action.type)) {
|
||||||
|
const location = action.payload?.location || action.payload;
|
||||||
|
const pathname = location?.pathname || window.location.pathname;
|
||||||
|
const search = location?.search || window.location.search;
|
||||||
|
|
||||||
|
// 识别页面类型
|
||||||
|
const pageProperties = getPageTypeFromPath(pathname);
|
||||||
|
|
||||||
|
// 追踪页面浏览
|
||||||
|
trackPageView(pathname, {
|
||||||
|
...pageProperties,
|
||||||
|
page_path: pathname,
|
||||||
|
page_search: search,
|
||||||
|
page_title: document.title,
|
||||||
|
referrer: document.referrer,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('PostHog Middleware', `页面浏览追踪: ${pathname}`, pageProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 3. 离线事件处理 ====================
|
||||||
|
// 检测网络状态变化
|
||||||
|
if (action.type === 'network/online') {
|
||||||
|
// 恢复在线时,刷新缓存的事件
|
||||||
|
const { eventQueue } = posthogState;
|
||||||
|
if (eventQueue && eventQueue.length > 0) {
|
||||||
|
logger.info('PostHog Middleware', `网络恢复,刷新 ${eventQueue.length} 个缓存事件`);
|
||||||
|
// 这里可以 dispatch flushCachedEvents,但为了避免循环依赖,直接在 slice 中处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 4. 性能追踪(可选) ====================
|
||||||
|
// 追踪耗时较长的 actions
|
||||||
|
const startTime = action.meta?.startTime;
|
||||||
|
if (startTime) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
if (duration > 1000) {
|
||||||
|
// 超过 1 秒的操作
|
||||||
|
store.dispatch(trackEvent({
|
||||||
|
eventName: SPECIAL_EVENTS.PAGE_LOAD_TIME,
|
||||||
|
properties: {
|
||||||
|
action_type: action.type,
|
||||||
|
duration_ms: duration,
|
||||||
|
is_slow: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PostHog Middleware', '追踪失败', error, { actionType: action.type });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 工具函数 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带性能追踪的 action creator
|
||||||
|
* 用法: dispatch(withTiming(someAction(payload)))
|
||||||
|
*/
|
||||||
|
export const withTiming = (action) => ({
|
||||||
|
...action,
|
||||||
|
meta: {
|
||||||
|
...action.meta,
|
||||||
|
startTime: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发页面浏览追踪
|
||||||
|
* 用于非路由跳转的场景(如 Modal、Tab 切换)
|
||||||
|
*/
|
||||||
|
export const trackModalView = (modalName, properties = {}) => (dispatch) => {
|
||||||
|
dispatch(trackEvent({
|
||||||
|
eventName: '$pageview',
|
||||||
|
properties: {
|
||||||
|
modal_name: modalName,
|
||||||
|
page_type: 'modal',
|
||||||
|
...properties,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪 Tab 切换
|
||||||
|
*/
|
||||||
|
export const trackTabChange = (tabName, properties = {}) => (dispatch) => {
|
||||||
|
dispatch(trackEvent({
|
||||||
|
eventName: RETENTION_EVENTS.NEWS_TAB_CLICKED,
|
||||||
|
properties: {
|
||||||
|
tab_name: tabName,
|
||||||
|
...properties,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Export ====================
|
||||||
|
|
||||||
|
export default posthogMiddleware;
|
||||||
299
src/store/slices/posthogSlice.js
Normal file
299
src/store/slices/posthogSlice.js
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
// src/store/slices/posthogSlice.js
|
||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import {
|
||||||
|
initPostHog,
|
||||||
|
identifyUser as posthogIdentifyUser,
|
||||||
|
resetUser as posthogResetUser,
|
||||||
|
trackEvent as posthogTrackEvent,
|
||||||
|
getFeatureFlag as posthogGetFeatureFlag,
|
||||||
|
optIn as posthogOptIn,
|
||||||
|
optOut as posthogOptOut,
|
||||||
|
hasOptedOut as posthogHasOptedOut
|
||||||
|
} from '../../lib/posthog';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
// ==================== Initial State ====================
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
// 初始化状态
|
||||||
|
isInitialized: false,
|
||||||
|
initError: null,
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
user: null,
|
||||||
|
|
||||||
|
// 事件队列(用于离线缓存)
|
||||||
|
eventQueue: [],
|
||||||
|
|
||||||
|
// Feature Flags
|
||||||
|
featureFlags: {},
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
config: {
|
||||||
|
apiKey: process.env.REACT_APP_POSTHOG_KEY || null,
|
||||||
|
apiHost: process.env.REACT_APP_POSTHOG_HOST || 'https://app.posthog.com',
|
||||||
|
sessionRecording: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
stats: {
|
||||||
|
totalEvents: 0,
|
||||||
|
lastEventTime: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Async Thunks ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 PostHog SDK
|
||||||
|
*/
|
||||||
|
export const initializePostHog = createAsyncThunk(
|
||||||
|
'posthog/initialize',
|
||||||
|
async (_, { getState, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const { config } = getState().posthog;
|
||||||
|
|
||||||
|
if (!config.apiKey) {
|
||||||
|
logger.warn('PostHog', '未配置 API Key,分析功能将被禁用');
|
||||||
|
return { isInitialized: false, warning: 'No API Key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 PostHog SDK 初始化
|
||||||
|
initPostHog();
|
||||||
|
|
||||||
|
logger.info('PostHog', 'Redux 初始化成功');
|
||||||
|
|
||||||
|
return { isInitialized: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PostHog', '初始化失败', error);
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别用户
|
||||||
|
*/
|
||||||
|
export const identifyUser = createAsyncThunk(
|
||||||
|
'posthog/identifyUser',
|
||||||
|
async ({ userId, userProperties }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
posthogIdentifyUser(userId, userProperties);
|
||||||
|
logger.info('PostHog', '用户已识别', { userId });
|
||||||
|
return { userId, userProperties };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PostHog', '用户识别失败', error);
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置用户会话(登出)
|
||||||
|
*/
|
||||||
|
export const resetUser = createAsyncThunk(
|
||||||
|
'posthog/resetUser',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
posthogResetUser();
|
||||||
|
logger.info('PostHog', '用户会话已重置');
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PostHog', '重置用户会话失败', error);
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪事件
|
||||||
|
*/
|
||||||
|
export const trackEvent = createAsyncThunk(
|
||||||
|
'posthog/trackEvent',
|
||||||
|
async ({ eventName, properties = {} }, { getState, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const { isInitialized } = getState().posthog;
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
logger.warn('PostHog', 'PostHog 未初始化,事件将被缓存', { eventName });
|
||||||
|
return { eventName, properties, cached: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
posthogTrackEvent(eventName, properties);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventName,
|
||||||
|
properties,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
cached: false
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PostHog', '追踪事件失败', error, { eventName });
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 Feature Flags
|
||||||
|
*/
|
||||||
|
export const fetchFeatureFlags = createAsyncThunk(
|
||||||
|
'posthog/fetchFeatureFlags',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
// PostHog SDK 会在初始化时自动获取 feature flags
|
||||||
|
// 这里只是读取缓存的值
|
||||||
|
const flags = {};
|
||||||
|
logger.info('PostHog', 'Feature Flags 已更新');
|
||||||
|
return flags;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PostHog', '获取 Feature Flags 失败', error);
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新缓存的离线事件
|
||||||
|
*/
|
||||||
|
export const flushCachedEvents = createAsyncThunk(
|
||||||
|
'posthog/flushCachedEvents',
|
||||||
|
async (_, { getState, dispatch }) => {
|
||||||
|
try {
|
||||||
|
const { eventQueue, isInitialized } = getState().posthog;
|
||||||
|
|
||||||
|
if (!isInitialized || eventQueue.length === 0) {
|
||||||
|
return { flushed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('PostHog', `刷新 ${eventQueue.length} 个缓存事件`);
|
||||||
|
|
||||||
|
// 批量发送缓存的事件
|
||||||
|
for (const { eventName, properties } of eventQueue) {
|
||||||
|
dispatch(trackEvent({ eventName, properties }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { flushed: eventQueue.length };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PostHog', '刷新缓存事件失败', error);
|
||||||
|
return { flushed: 0, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== Slice ====================
|
||||||
|
|
||||||
|
const posthogSlice = createSlice({
|
||||||
|
name: 'posthog',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
// 设置 Feature Flag
|
||||||
|
setFeatureFlag: (state, action) => {
|
||||||
|
const { flagKey, value } = action.payload;
|
||||||
|
state.featureFlags[flagKey] = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空事件队列
|
||||||
|
clearEventQueue: (state) => {
|
||||||
|
state.eventQueue = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新配置
|
||||||
|
updateConfig: (state, action) => {
|
||||||
|
state.config = { ...state.config, ...action.payload };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 用户 Opt-in
|
||||||
|
optIn: (state) => {
|
||||||
|
posthogOptIn();
|
||||||
|
logger.info('PostHog', '用户已选择加入追踪');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 用户 Opt-out
|
||||||
|
optOut: (state) => {
|
||||||
|
posthogOptOut();
|
||||||
|
logger.info('PostHog', '用户已选择退出追踪');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// 初始化
|
||||||
|
builder.addCase(initializePostHog.fulfilled, (state, action) => {
|
||||||
|
state.isInitialized = action.payload.isInitialized;
|
||||||
|
state.initError = null;
|
||||||
|
});
|
||||||
|
builder.addCase(initializePostHog.rejected, (state, action) => {
|
||||||
|
state.isInitialized = false;
|
||||||
|
state.initError = action.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 识别用户
|
||||||
|
builder.addCase(identifyUser.fulfilled, (state, action) => {
|
||||||
|
state.user = {
|
||||||
|
userId: action.payload.userId,
|
||||||
|
...action.payload.userProperties,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置用户
|
||||||
|
builder.addCase(resetUser.fulfilled, (state) => {
|
||||||
|
state.user = null;
|
||||||
|
state.featureFlags = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 追踪事件
|
||||||
|
builder.addCase(trackEvent.fulfilled, (state, action) => {
|
||||||
|
const { eventName, properties, timestamp, cached } = action.payload;
|
||||||
|
|
||||||
|
// 如果事件被缓存,添加到队列
|
||||||
|
if (cached) {
|
||||||
|
state.eventQueue.push({ eventName, properties, timestamp });
|
||||||
|
} else {
|
||||||
|
// 更新统计
|
||||||
|
state.stats.totalEvents += 1;
|
||||||
|
state.stats.lastEventTime = timestamp;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 刷新缓存事件
|
||||||
|
builder.addCase(flushCachedEvents.fulfilled, (state, action) => {
|
||||||
|
if (action.payload.flushed > 0) {
|
||||||
|
state.eventQueue = [];
|
||||||
|
state.stats.totalEvents += action.payload.flushed;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取 Feature Flags
|
||||||
|
builder.addCase(fetchFeatureFlags.fulfilled, (state, action) => {
|
||||||
|
state.featureFlags = action.payload;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setFeatureFlag,
|
||||||
|
clearEventQueue,
|
||||||
|
updateConfig,
|
||||||
|
optIn,
|
||||||
|
optOut,
|
||||||
|
} = posthogSlice.actions;
|
||||||
|
|
||||||
|
// ==================== Selectors ====================
|
||||||
|
|
||||||
|
export const selectPostHog = (state) => state.posthog;
|
||||||
|
export const selectIsInitialized = (state) => state.posthog.isInitialized;
|
||||||
|
export const selectUser = (state) => state.posthog.user;
|
||||||
|
export const selectFeatureFlags = (state) => state.posthog.featureFlags;
|
||||||
|
export const selectEventQueue = (state) => state.posthog.eventQueue;
|
||||||
|
export const selectStats = (state) => state.posthog.stats;
|
||||||
|
|
||||||
|
export const selectFeatureFlag = (flagKey) => (state) => {
|
||||||
|
return state.posthog.featureFlags[flagKey] || posthogGetFeatureFlag(flagKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectIsOptedOut = () => posthogHasOptedOut();
|
||||||
|
|
||||||
|
// ==================== Export ====================
|
||||||
|
|
||||||
|
export default posthogSlice.reducer;
|
||||||
Reference in New Issue
Block a user