Files
vf_react/src/store/slices/stockSlice.js
zdl c1e10e6205 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_2025/251209_stock_pref
* feature_bugfix/251201_vf_h5_ui:
  feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步
  feat: 投资日历自选股功能优化 - Redux 集成 + 乐观更新
  fix: 修复投资日历切换月份时自动打开事件弹窗的问题
  fix: 修复 CompanyOverview 中 Hooks 顺序错误
2025-12-09 16:36:46 +08:00

510 lines
14 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';
// ==================== 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 };
}
);
/**
* 加载用户自选股列表(包含完整信息)
*/
export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist',
async () => {
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) {
// 返回完整的股票信息,而不仅仅是 stock_code
const watchlistData = data.data.map(item => ({
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
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: 乐观更新模式下状态已在 pending 更新,这里无需操作
.addCase(toggleWatchlist.fulfilled, () => {
// 状态已在 pending 时更新
});
}
});
export const {
updateQuote,
updateQuotes,
clearQuotes,
clearEventCache,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} = stockSlice.actions;
export default stockSlice.reducer;