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