Files
vf_react/src/store/slices/stockSlice.js
zdl 376b7b9724 perf: loadWatchlist 添加 localStorage 缓存(7天有效期)
- 添加 loadWatchlistFromCache/saveWatchlistToCache 缓存工具函数
- loadWatchlist 三级缓存策略:Redux → localStorage → API
- toggleWatchlist 成功后自动同步更新缓存
- 减少重复 API 请求,提升页面加载性能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:01:33 +08:00

580 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/store/slices/stockSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { eventService, stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
// ==================== Watchlist 缓存配置 ====================
const WATCHLIST_CACHE_KEY = 'watchlist_cache';
const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天
/**
* 从 localStorage 读取自选股缓存
*/
const loadWatchlistFromCache = () => {
try {
const cached = localStorage.getItem(WATCHLIST_CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 检查缓存是否过期7天
if (now - timestamp > WATCHLIST_CACHE_DURATION) {
localStorage.removeItem(WATCHLIST_CACHE_KEY);
logger.debug('stockSlice', '自选股缓存已过期');
return null;
}
logger.debug('stockSlice', '自选股 localStorage 缓存命中', {
count: data?.length || 0,
age: Math.round((now - timestamp) / 1000 / 60) + '分钟前'
});
return data;
} catch (error) {
logger.error('stockSlice', 'loadWatchlistFromCache', error);
return null;
}
};
/**
* 保存自选股到 localStorage
*/
const saveWatchlistToCache = (data) => {
try {
localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
logger.debug('stockSlice', '自选股已缓存到 localStorage', {
count: data?.length || 0
});
} catch (error) {
logger.error('stockSlice', 'saveWatchlistToCache', error);
}
};
// ==================== Async Thunks ====================
/**
* 获取事件相关股票Redux 缓存)
*/
export const fetchEventStocks = createAsyncThunk(
'stock/fetchEventStocks',
async ({ eventId, forceRefresh = false }, { getState }) => {
logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh });
// Redux 状态缓存
if (!forceRefresh) {
const cached = getState().stock.eventStocksCache[eventId];
if (cached && cached.length > 0) {
logger.debug('stockSlice', 'Redux 缓存命中', { eventId });
return { eventId, stocks: cached };
}
}
// API 请求
const res = await eventService.getRelatedStocks(eventId);
if (res.success && res.data) {
logger.debug('stockSlice', 'API 请求成功', {
eventId,
stockCount: res.data.length
});
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;
}
);
/**
* 获取事件详情Redux 缓存)
*/
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 };
}
}
// API 请求
const res = await eventService.getEventDetail(eventId);
if (res.success && res.data) {
return { eventId, detail: res.data };
}
throw new Error(res.error || '获取事件详情失败');
}
);
/**
* 获取历史事件对比Redux 缓存)
*/
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 };
}
}
// API 请求
const res = await eventService.getHistoricalEvents(eventId);
if (res.success && res.data) {
return { eventId, events: res.data };
}
return { eventId, events: [] };
}
);
/**
* 获取传导链分析Redux 缓存)
*/
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 };
}
}
// API 请求
const res = await eventService.getTransmissionChainAnalysis(eventId);
if (res.success && res.data) {
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 };
}
);
/**
* 加载用户自选股列表(包含完整信息)
* 缓存策略Redux 内存缓存 → localStorage 持久缓存7天 → API 请求
*/
export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist',
async (_, { getState }) => {
logger.debug('stockSlice', 'loadWatchlist');
try {
// 1. 先检查 Redux 内存缓存
const reduxCached = getState().stock.watchlist;
if (reduxCached && reduxCached.length > 0) {
logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length });
return reduxCached;
}
// 2. 再检查 localStorage 持久缓存7天有效期
const localCached = loadWatchlistFromCache();
if (localCached && localCached.length > 0) {
return localCached;
}
// 3. 缓存无效,调用 API
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/watchlist`, {
credentials: 'include'
});
const data = await response.json();
if (data.success && data.data) {
// 返回完整的股票信息,而不仅仅是 stock_code
const watchlistData = data.data.map(item => ({
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
// 保存到 localStorage 缓存
saveWatchlistToCache(watchlistData);
logger.debug('stockSlice', '自选股列表加载成功', {
count: watchlistData.length
});
return watchlistData;
}
return [];
} catch (error) {
logger.error('stockSlice', 'loadWatchlist', error);
return [];
}
}
);
/**
* 加载全部股票列表(用于前端模糊搜索)
*/
export const loadAllStocks = createAsyncThunk(
'stock/loadAllStocks',
async (_, { getState }) => {
// 检查缓存
const cached = getState().stock.allStocks;
if (cached && cached.length > 0) {
logger.debug('stockSlice', 'allStocks 缓存命中', { count: cached.length });
return cached;
}
logger.debug('stockSlice', 'loadAllStocks');
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/stocklist`, {
credentials: 'include'
});
const data = await response.json();
if (Array.isArray(data)) {
logger.debug('stockSlice', '全部股票列表加载成功', {
count: data.length
});
return data;
}
return [];
} catch (error) {
logger.error('stockSlice', 'loadAllStocks', 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, stockName, 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: {},
// 自选股列表 [{ stock_code, stock_name }]
watchlist: [],
// 全部股票列表(用于前端模糊搜索)[{ code, name }]
allStocks: [],
// 加载状态
loading: {
stocks: false,
quotes: false,
eventDetail: false,
historicalEvents: false,
chainAnalysis: false,
watchlist: false,
allStocks: 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];
},
/**
* 乐观更新:添加自选股(同步)
*/
optimisticAddWatchlist: (state, action) => {
const { stockCode, stockName } = action.payload;
// 避免重复添加
const exists = state.watchlist.some(item => item.stock_code === stockCode);
if (!exists) {
state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' });
}
},
/**
* 乐观更新:移除自选股(同步)
*/
optimisticRemoveWatchlist: (state, action) => {
const { stockCode } = action.payload;
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
},
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;
})
// ===== loadAllStocks =====
.addCase(loadAllStocks.pending, (state) => {
state.loading.allStocks = true;
})
.addCase(loadAllStocks.fulfilled, (state, action) => {
state.allStocks = action.payload;
state.loading.allStocks = false;
})
.addCase(loadAllStocks.rejected, (state) => {
state.loading.allStocks = false;
})
// ===== toggleWatchlist乐观更新=====
// pending: 立即更新状态
.addCase(toggleWatchlist.pending, (state, action) => {
const { stockCode, stockName, isInWatchlist } = action.meta.arg;
if (isInWatchlist) {
// 移除
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
} else {
// 添加
const exists = state.watchlist.some(item => item.stock_code === stockCode);
if (!exists) {
state.watchlist.push({ stock_code: stockCode, stock_name: stockName });
}
}
})
// rejected: 回滚状态
.addCase(toggleWatchlist.rejected, (state, action) => {
const { stockCode, stockName, isInWatchlist } = action.meta.arg;
// 回滚:与 pending 操作相反
if (isInWatchlist) {
// 之前移除了,现在加回来
const exists = state.watchlist.some(item => item.stock_code === stockCode);
if (!exists) {
state.watchlist.push({ stock_code: stockCode, stock_name: stockName });
}
} else {
// 之前添加了,现在移除
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
})
// fulfilled: 同步更新 localStorage 缓存
.addCase(toggleWatchlist.fulfilled, (state) => {
// 状态已在 pending 时更新,这里同步到 localStorage
saveWatchlistToCache(state.watchlist);
});
}
});
export const {
updateQuote,
updateQuotes,
clearQuotes,
clearEventCache,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} = stockSlice.actions;
export default stockSlice.reducer;