* feature_bugfix/251201_vf_h5_ui: feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步 feat: 投资日历自选股功能优化 - Redux 集成 + 乐观更新 fix: 修复投资日历切换月份时自动打开事件弹窗的问题 fix: 修复 CompanyOverview 中 Hooks 顺序错误
510 lines
14 KiB
JavaScript
510 lines
14 KiB
JavaScript
// 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;
|