diff --git a/src/store/index.js b/src/store/index.js index 4d2ae544..d983721f 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -3,6 +3,7 @@ import { configureStore } from '@reduxjs/toolkit'; import communityDataReducer from './slices/communityDataSlice'; import posthogReducer from './slices/posthogSlice'; import industryReducer from './slices/industrySlice'; +import stockReducer from './slices/stockSlice'; import posthogMiddleware from './middleware/posthogMiddleware'; export const store = configureStore({ @@ -10,6 +11,7 @@ export const store = configureStore({ communityData: communityDataReducer, posthog: posthogReducer, // ✅ PostHog Redux 状态管理 industry: industryReducer, // ✅ 行业分类数据管理 + stock: stockReducer, // ✅ 股票和事件数据管理 }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -19,6 +21,8 @@ export const store = configureStore({ 'communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled', 'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪 + 'stock/fetchEventStocks/fulfilled', + 'stock/fetchStockQuotes/fulfilled', ], }, }).concat(posthogMiddleware), // ✅ PostHog 自动追踪中间件 diff --git a/src/store/slices/stockSlice.js b/src/store/slices/stockSlice.js new file mode 100644 index 00000000..a33185ba --- /dev/null +++ b/src/store/slices/stockSlice.js @@ -0,0 +1,479 @@ +// src/store/slices/stockSlice.js +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { eventService, stockService } from '../../services/eventService'; +import { logger } from '../../utils/logger'; +import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager'; +import { getApiBase } from '../../utils/apiConfig'; + +// ==================== 常量定义 ==================== + +// 缓存键名 +const CACHE_KEYS = { + EVENT_STOCKS: 'event_stocks_', + EVENT_DETAIL: 'event_detail_', + HISTORICAL_EVENTS: 'historical_events_', + CHAIN_ANALYSIS: 'chain_analysis_', + EXPECTATION_SCORE: 'expectation_score_', + WATCHLIST: 'user_watchlist' +}; + +// 请求去重:缓存正在进行的请求 +const pendingRequests = new Map(); + +// ==================== Async Thunks ==================== + +/** + * 获取事件相关股票(三级缓存) + */ +export const fetchEventStocks = createAsyncThunk( + 'stock/fetchEventStocks', + async ({ eventId, forceRefresh = false }, { getState }) => { + logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh }); + + // 1. Redux 状态缓存 + if (!forceRefresh) { + const cached = getState().stock.eventStocksCache[eventId]; + if (cached && cached.length > 0) { + logger.debug('stockSlice', 'Redux 缓存命中', { eventId }); + return { eventId, stocks: cached }; + } + } + + // 2. LocalStorage 缓存 + if (!forceRefresh) { + const localCached = localCacheManager.get(CACHE_KEYS.EVENT_STOCKS + eventId); + if (localCached) { + logger.debug('stockSlice', 'LocalStorage 缓存命中', { eventId }); + return { eventId, stocks: localCached }; + } + } + + // 3. API 请求 + const res = await eventService.getRelatedStocks(eventId); + if (res.success && res.data) { + logger.debug('stockSlice', 'API 请求成功', { + eventId, + stockCount: res.data.length + }); + localCacheManager.set( + CACHE_KEYS.EVENT_STOCKS + eventId, + res.data, + CACHE_EXPIRY_STRATEGY.LONG // 1小时 + ); + return { eventId, stocks: res.data }; + } + + throw new Error(res.error || '获取股票数据失败'); + } +); + +/** + * 获取股票行情 + */ +export const fetchStockQuotes = createAsyncThunk( + 'stock/fetchStockQuotes', + async ({ codes, eventTime }) => { + logger.debug('stockSlice', 'fetchStockQuotes', { + codeCount: codes.length, + eventTime + }); + + const quotes = await stockService.getQuotes(codes, eventTime); + return quotes; + } +); + +/** + * 获取事件详情 + */ +export const fetchEventDetail = createAsyncThunk( + 'stock/fetchEventDetail', + async ({ eventId, forceRefresh = false }, { getState }) => { + logger.debug('stockSlice', 'fetchEventDetail', { eventId }); + + // Redux 缓存 + if (!forceRefresh) { + const cached = getState().stock.eventDetailsCache[eventId]; + if (cached) { + logger.debug('stockSlice', 'Redux 缓存命中 - eventDetail', { eventId }); + return { eventId, detail: cached }; + } + } + + // LocalStorage 缓存 + if (!forceRefresh) { + const localCached = localCacheManager.get(CACHE_KEYS.EVENT_DETAIL + eventId); + if (localCached) { + logger.debug('stockSlice', 'LocalStorage 缓存命中 - eventDetail', { eventId }); + return { eventId, detail: localCached }; + } + } + + // API 请求 + const res = await eventService.getEventDetail(eventId); + if (res.success && res.data) { + localCacheManager.set( + CACHE_KEYS.EVENT_DETAIL + eventId, + res.data, + CACHE_EXPIRY_STRATEGY.LONG + ); + return { eventId, detail: res.data }; + } + + throw new Error(res.error || '获取事件详情失败'); + } +); + +/** + * 获取历史事件对比 + */ +export const fetchHistoricalEvents = createAsyncThunk( + 'stock/fetchHistoricalEvents', + async ({ eventId, forceRefresh = false }, { getState }) => { + logger.debug('stockSlice', 'fetchHistoricalEvents', { eventId }); + + // Redux 缓存 + if (!forceRefresh) { + const cached = getState().stock.historicalEventsCache[eventId]; + if (cached) { + return { eventId, events: cached }; + } + } + + // LocalStorage 缓存 + if (!forceRefresh) { + const localCached = localCacheManager.get(CACHE_KEYS.HISTORICAL_EVENTS + eventId); + if (localCached) { + return { eventId, events: localCached }; + } + } + + // API 请求 + const res = await eventService.getHistoricalEvents(eventId); + if (res.success && res.data) { + localCacheManager.set( + CACHE_KEYS.HISTORICAL_EVENTS + eventId, + res.data, + CACHE_EXPIRY_STRATEGY.LONG + ); + return { eventId, events: res.data }; + } + + return { eventId, events: [] }; + } +); + +/** + * 获取传导链分析 + */ +export const fetchChainAnalysis = createAsyncThunk( + 'stock/fetchChainAnalysis', + async ({ eventId, forceRefresh = false }, { getState }) => { + logger.debug('stockSlice', 'fetchChainAnalysis', { eventId }); + + // Redux 缓存 + if (!forceRefresh) { + const cached = getState().stock.chainAnalysisCache[eventId]; + if (cached) { + return { eventId, analysis: cached }; + } + } + + // LocalStorage 缓存 + if (!forceRefresh) { + const localCached = localCacheManager.get(CACHE_KEYS.CHAIN_ANALYSIS + eventId); + if (localCached) { + return { eventId, analysis: localCached }; + } + } + + // API 请求 + const res = await eventService.getTransmissionChainAnalysis(eventId); + if (res.success && res.data) { + localCacheManager.set( + CACHE_KEYS.CHAIN_ANALYSIS + eventId, + res.data, + CACHE_EXPIRY_STRATEGY.LONG + ); + return { eventId, analysis: res.data }; + } + + return { eventId, analysis: null }; + } +); + +/** + * 获取超预期得分 + */ +export const fetchExpectationScore = createAsyncThunk( + 'stock/fetchExpectationScore', + async ({ eventId }) => { + logger.debug('stockSlice', 'fetchExpectationScore', { eventId }); + + if (eventService.getExpectationScore) { + const res = await eventService.getExpectationScore(eventId); + if (res.success && res.data) { + return { eventId, score: res.data.score }; + } + } + + return { eventId, score: null }; + } +); + +/** + * 加载用户自选股列表 + */ +export const loadWatchlist = createAsyncThunk( + 'stock/loadWatchlist', + async (_, { getState }) => { + logger.debug('stockSlice', 'loadWatchlist'); + + try { + const apiBase = getApiBase(); + const response = await fetch(`${apiBase}/api/account/watchlist`, { + credentials: 'include' + }); + const data = await response.json(); + + if (data.success && data.data) { + const stockCodes = data.data.map(item => item.stock_code); + logger.debug('stockSlice', '自选股列表加载成功', { + count: stockCodes.length + }); + return stockCodes; + } + + return []; + } catch (error) { + logger.error('stockSlice', 'loadWatchlist', error); + return []; + } + } +); + +/** + * 切换自选股状态 + */ +export const toggleWatchlist = createAsyncThunk( + 'stock/toggleWatchlist', + async ({ stockCode, stockName, isInWatchlist }) => { + logger.debug('stockSlice', 'toggleWatchlist', { + stockCode, + stockName, + isInWatchlist + }); + + const apiBase = getApiBase(); + let response; + + if (isInWatchlist) { + // 移除自选股 + response = await fetch(`${apiBase}/api/account/watchlist/${stockCode}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include' + }); + } else { + // 添加自选股 + response = await fetch(`${apiBase}/api/account/watchlist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ stock_code: stockCode, stock_name: stockName }) + }); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || '操作失败'); + } + + return { stockCode, isInWatchlist }; + } +); + +// ==================== Slice ==================== + +const stockSlice = createSlice({ + name: 'stock', + initialState: { + // 事件相关股票缓存 { [eventId]: stocks[] } + eventStocksCache: {}, + + // 股票行情 { [stockCode]: quote } + quotes: {}, + + // 事件详情缓存 { [eventId]: detail } + eventDetailsCache: {}, + + // 历史事件缓存 { [eventId]: events[] } + historicalEventsCache: {}, + + // 传导链分析缓存 { [eventId]: analysis } + chainAnalysisCache: {}, + + // 超预期得分缓存 { [eventId]: score } + expectationScores: {}, + + // 自选股列表 Set + watchlist: [], + + // 加载状态 + loading: { + stocks: false, + quotes: false, + eventDetail: false, + historicalEvents: false, + chainAnalysis: false, + watchlist: false + }, + + // 错误信息 + error: null + }, + reducers: { + /** + * 更新单个股票行情 + */ + updateQuote: (state, action) => { + const { stockCode, quote } = action.payload; + state.quotes[stockCode] = quote; + }, + + /** + * 批量更新股票行情 + */ + updateQuotes: (state, action) => { + state.quotes = { ...state.quotes, ...action.payload }; + }, + + /** + * 清空行情数据 + */ + clearQuotes: (state) => { + state.quotes = {}; + }, + + /** + * 清空指定事件的缓存 + */ + clearEventCache: (state, action) => { + const { eventId } = action.payload; + delete state.eventStocksCache[eventId]; + delete state.eventDetailsCache[eventId]; + delete state.historicalEventsCache[eventId]; + delete state.chainAnalysisCache[eventId]; + delete state.expectationScores[eventId]; + } + }, + extraReducers: (builder) => { + builder + // ===== fetchEventStocks ===== + .addCase(fetchEventStocks.pending, (state) => { + state.loading.stocks = true; + state.error = null; + }) + .addCase(fetchEventStocks.fulfilled, (state, action) => { + const { eventId, stocks } = action.payload; + state.eventStocksCache[eventId] = stocks; + state.loading.stocks = false; + }) + .addCase(fetchEventStocks.rejected, (state, action) => { + state.loading.stocks = false; + state.error = action.error.message; + }) + + // ===== fetchStockQuotes ===== + .addCase(fetchStockQuotes.pending, (state) => { + state.loading.quotes = true; + }) + .addCase(fetchStockQuotes.fulfilled, (state, action) => { + state.quotes = { ...state.quotes, ...action.payload }; + state.loading.quotes = false; + }) + .addCase(fetchStockQuotes.rejected, (state) => { + state.loading.quotes = false; + }) + + // ===== fetchEventDetail ===== + .addCase(fetchEventDetail.pending, (state) => { + state.loading.eventDetail = true; + }) + .addCase(fetchEventDetail.fulfilled, (state, action) => { + const { eventId, detail } = action.payload; + state.eventDetailsCache[eventId] = detail; + state.loading.eventDetail = false; + }) + .addCase(fetchEventDetail.rejected, (state) => { + state.loading.eventDetail = false; + }) + + // ===== fetchHistoricalEvents ===== + .addCase(fetchHistoricalEvents.pending, (state) => { + state.loading.historicalEvents = true; + }) + .addCase(fetchHistoricalEvents.fulfilled, (state, action) => { + const { eventId, events } = action.payload; + state.historicalEventsCache[eventId] = events; + state.loading.historicalEvents = false; + }) + .addCase(fetchHistoricalEvents.rejected, (state) => { + state.loading.historicalEvents = false; + }) + + // ===== fetchChainAnalysis ===== + .addCase(fetchChainAnalysis.pending, (state) => { + state.loading.chainAnalysis = true; + }) + .addCase(fetchChainAnalysis.fulfilled, (state, action) => { + const { eventId, analysis } = action.payload; + state.chainAnalysisCache[eventId] = analysis; + state.loading.chainAnalysis = false; + }) + .addCase(fetchChainAnalysis.rejected, (state) => { + state.loading.chainAnalysis = false; + }) + + // ===== fetchExpectationScore ===== + .addCase(fetchExpectationScore.fulfilled, (state, action) => { + const { eventId, score } = action.payload; + state.expectationScores[eventId] = score; + }) + + // ===== loadWatchlist ===== + .addCase(loadWatchlist.pending, (state) => { + state.loading.watchlist = true; + }) + .addCase(loadWatchlist.fulfilled, (state, action) => { + state.watchlist = action.payload; + state.loading.watchlist = false; + }) + .addCase(loadWatchlist.rejected, (state) => { + state.loading.watchlist = false; + }) + + // ===== toggleWatchlist ===== + .addCase(toggleWatchlist.fulfilled, (state, action) => { + const { stockCode, isInWatchlist } = action.payload; + if (isInWatchlist) { + // 移除 + state.watchlist = state.watchlist.filter(code => code !== stockCode); + } else { + // 添加 + if (!state.watchlist.includes(stockCode)) { + state.watchlist.push(stockCode); + } + } + }); + } +}); + +export const { + updateQuote, + updateQuotes, + clearQuotes, + clearEventCache +} = stockSlice.actions; + +export default stockSlice.reducer;