// src/store/slices/communityDataSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import { eventService } from '../../services/eventService'; import { logger } from '../../utils/logger'; import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager'; // ==================== 常量定义 ==================== // 缓存键名 const CACHE_KEYS = { POPULAR_KEYWORDS: 'community_popular_keywords', HOT_EVENTS: 'community_hot_events' }; // 请求去重:缓存正在进行的请求 const pendingRequests = new Map(); // ==================== 通用数据获取逻辑 ==================== /** * 通用的数据获取函数(支持三级缓存 + 请求去重) * @param {Object} options - 配置选项 * @param {string} options.cacheKey - 缓存键名 * @param {Function} options.fetchFn - API 获取函数 * @param {Function} options.getState - Redux getState 函数 * @param {string} options.stateKey - Redux state 中的键名 * @param {boolean} options.forceRefresh - 是否强制刷新 * @returns {Promise} 获取的数据 */ const fetchWithCache = async ({ cacheKey, fetchFn, getState, stateKey, forceRefresh = false }) => { // 请求去重:如果有正在进行的相同请求,直接返回该 Promise if (!forceRefresh && pendingRequests.has(cacheKey)) { logger.debug('CommunityData', `复用进行中的请求: ${stateKey}`); return pendingRequests.get(cacheKey); } const requestPromise = (async () => { try { // 第一级缓存:检查 Redux 状态(除非强制刷新) if (!forceRefresh) { const stateData = getState().communityData[stateKey]; if (stateData && stateData.length > 0) { logger.debug('CommunityData', `Redux 状态中已有${stateKey}数据`); return stateData; } // 第二级缓存:检查 localStorage const cachedData = localCacheManager.get(cacheKey); if (cachedData) { return cachedData; } } // 第三级:从 API 获取 logger.debug('CommunityData', `从 API 获取${stateKey}`, { forceRefresh }); const response = await fetchFn(); if (response.success && response.data) { // 保存到 localStorage(午夜过期) localCacheManager.set(cacheKey, response.data, CACHE_EXPIRY_STRATEGY.MIDNIGHT); return response.data; } logger.warn('CommunityData', `API 返回数据为空:${stateKey}`); return []; } catch (error) { logger.error('CommunityData', `获取${stateKey}失败`, error); throw error; } finally { // 请求完成后清除缓存 pendingRequests.delete(cacheKey); } })(); // 缓存请求 Promise if (!forceRefresh) { pendingRequests.set(cacheKey, requestPromise); } return requestPromise; }; // ==================== Reducer 工厂函数 ==================== /** * 创建通用的 reducer cases * @param {Object} builder - Redux Toolkit builder * @param {Object} asyncThunk - createAsyncThunk 返回的对象 * @param {string} dataKey - state 中的数据键名(如 'popularKeywords') */ const createDataReducers = (builder, asyncThunk, dataKey) => { builder .addCase(asyncThunk.pending, (state) => { state.loading[dataKey] = true; state.error[dataKey] = null; }) .addCase(asyncThunk.fulfilled, (state, action) => { state.loading[dataKey] = false; state[dataKey] = action.payload; }) .addCase(asyncThunk.rejected, (state, action) => { state.loading[dataKey] = false; state.error[dataKey] = action.payload; logger.error('CommunityData', `${dataKey} 加载失败`, new Error(action.payload)); }); }; // ==================== Async Thunks ==================== /** * 获取热门关键词 * @param {boolean} forceRefresh - 是否强制刷新(跳过缓存) */ export const fetchPopularKeywords = createAsyncThunk( 'communityData/fetchPopularKeywords', async (forceRefresh = false, { getState, rejectWithValue }) => { try { return await fetchWithCache({ cacheKey: CACHE_KEYS.POPULAR_KEYWORDS, fetchFn: () => eventService.getPopularKeywords(20), getState, stateKey: 'popularKeywords', forceRefresh }); } catch (error) { return rejectWithValue(error.message || '获取热门关键词失败'); } } ); /** * 获取热点事件 * @param {boolean} forceRefresh - 是否强制刷新(跳过缓存) */ export const fetchHotEvents = createAsyncThunk( 'communityData/fetchHotEvents', async (forceRefresh = false, { getState, rejectWithValue }) => { try { return await fetchWithCache({ cacheKey: CACHE_KEYS.HOT_EVENTS, fetchFn: () => eventService.getHotEvents({ days: 5, limit: 20 }), getState, stateKey: 'hotEvents', forceRefresh }); } catch (error) { return rejectWithValue(error.message || '获取热点事件失败'); } } ); /** * 获取动态新闻(客户端缓存 + 虚拟滚动) * 用于 DynamicNewsCard 组件 * @param {Object} params - 请求参数 * @param {string} params.mode - 显示模式('vertical' | 'four-row') * @param {number} params.page - 页码 * @param {number} params.per_page - 每页数量(可选,不提供时自动根据 mode 计算) * @param {boolean} params.clearCache - 是否清空缓存(默认 false) * @param {boolean} params.prependMode - 是否追加到头部(用于定时刷新,默认 false) * @param {string} params.sort - 排序方式(new/hot) * @param {string} params.importance - 重要性筛选(all/1/2/3/4/5) * @param {string} params.q - 搜索关键词 * @param {string} params.date_range - 时间范围 * @param {string} params.industry_code - 行业代码 */ export const fetchDynamicNews = createAsyncThunk( 'communityData/fetchDynamicNews', async ({ mode = 'vertical', page = 1, per_page, // 移除默认值,下面动态计算 pageSize, // 向后兼容(已废弃,使用 per_page) clearCache = false, prependMode = false, sort = 'new', importance, q, date_range, // 兼容旧格式(已废弃) industry_code, // 时间筛选参数(从 TradingTimeFilter 传递) start_date, end_date, recent_days } = {}, { rejectWithValue }) => { try { // 【动态计算 per_page】根据 mode 自动选择合适的每页大小 // - 平铺模式 (four-row): 30 条(7.5行 × 4列,提供充足的缓冲数据) // - 纵向模式 (vertical): 10 条(传统分页) // 优先使用传入的 per_page,其次使用 pageSize(向后兼容),最后根据 mode 计算 const finalPerPage = per_page || pageSize || (mode === 'four-row' ? 30 : 10); // 构建筛选参数 const filters = {}; if (sort) filters.sort = sort; if (importance && importance !== 'all') filters.importance = importance; if (q) filters.q = q; if (date_range) filters.date_range = date_range; // 兼容旧格式 if (industry_code) filters.industry_code = industry_code; // 时间筛选参数 if (start_date) filters.start_date = start_date; if (end_date) filters.end_date = end_date; if (recent_days) filters.recent_days = recent_days; logger.debug('CommunityData', '开始获取动态新闻', { mode, page, per_page: finalPerPage, clearCache, prependMode, filters }); const response = await eventService.getEvents({ page, per_page: finalPerPage, ...filters }); if (response.success && response.data?.events) { logger.info('CommunityData', '动态新闻加载成功', { count: response.data.events.length, page: response.data.pagination?.page || page, total: response.data.pagination?.total || 0, per_page: finalPerPage }); // 【兜底处理】支持多种 pagination 字段名:pages (后端) / total_pages (旧Mock) / totalPages const paginationData = response.data.pagination || {}; const calculatedTotalPages = paginationData.pages // ← 后端格式 (优先) || paginationData.total_pages // ← Mock 旧格式 (兼容) || paginationData.totalPages // ← 其他可能格式 || Math.ceil((paginationData.total || 0) / finalPerPage); // ← 兜底计算 return { mode, events: response.data.events, total: paginationData.total || 0, totalPages: calculatedTotalPages, page, per_page: finalPerPage, clearCache, prependMode }; } logger.warn('CommunityData', '动态新闻返回数据为空', response); // 【兜底处理】空数据情况也尝试读取 pagination const emptyPaginationData = response.data?.pagination || {}; const emptyTotalPages = emptyPaginationData.pages || emptyPaginationData.total_pages || 0; return { mode, events: [], total: 0, totalPages: emptyTotalPages, page, per_page: finalPerPage, clearCache, prependMode, isEmpty: true // 标记为空数据,用于边界条件处理 }; } catch (error) { logger.error('CommunityData', '获取动态新闻失败', error); return rejectWithValue(error.message || '获取动态新闻失败'); } } ); /** * 切换事件关注状态 * 复用 EventList.js 中的关注逻辑 * @param {number} eventId - 事件ID */ export const toggleEventFollow = createAsyncThunk( 'communityData/toggleEventFollow', async (eventId, { rejectWithValue }) => { try { logger.debug('CommunityData', '切换事件关注状态', { eventId }); // 调用 API(自动切换关注状态,后端根据当前状态决定关注/取消关注) const response = await fetch(`/api/events/${eventId}/follow`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include' }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || '操作失败'); } const isFollowing = data.data?.is_following; const followerCount = data.data?.follower_count ?? 0; logger.info('CommunityData', '关注状态切换成功', { eventId, isFollowing, followerCount }); return { eventId, isFollowing, followerCount }; } catch (error) { logger.error('CommunityData', '切换关注状态失败', error); return rejectWithValue(error.message || '切换关注状态失败'); } } ); // ==================== Slice 定义 ==================== /** * 【Redux State 架构设计】 * * 核心原则: * 1. **模式独立存储**: verticalEvents 和 fourRowEvents 完全独立 * - 原因:两种模式使用不同的 pageSize (10 vs 30),共享缓存会导致分页混乱 * - 代价:~50% 内存冗余(同一事件可能存在于两个数组) * - 权衡:简化逻辑复杂度,避免 pageSize 切换时的边界计算问题 * * 2. **数据去重**: 使用 Set 去重,防止重复事件 * - 场景1:网络请求乱序(慢请求后返回) * - 场景2:定时刷新 + prepend 模式(新事件插入头部) * - 场景3:后端分页漂移(新数据导致页码偏移) * * 3. **追加模式 (append)**: 虚拟滚动必须使用累积数组 * - 原因:虚拟滚动需要完整数据计算 totalHeight * - 对比:传统分页每次替换数据(page mode) * * 4. **加载状态管理**: 分模式独立管理 loading/error * - 避免模式切换时的加载状态冲突 */ const communityDataSlice = createSlice({ name: 'communityData', initialState: { // 数据 popularKeywords: [], hotEvents: [], // 【纵向模式】独立存储(传统分页 + 每页10条) verticalEventsByPage: {}, // 页码映射存储 { 1: [10条], 2: [8条], 3: [10条] } verticalPagination: { // 分页元数据 total: 0, // 总记录数 total_pages: 0, // 总页数 current_page: 1, // 当前页码 per_page: 10 // 每页大小 }, // 【平铺模式】独立存储(虚拟滚动 + 每页30条) fourRowEvents: [], // 完整缓存列表(虚拟滚动的数据源) fourRowPagination: { // 分页元数据 total: 0, // 总记录数 total_pages: 0, // 总页数 current_page: 1, // 当前页码 per_page: 30 // 每页大小 }, eventFollowStatus: {}, // 事件关注状态(全局共享){ [eventId]: { isFollowing: boolean, followerCount: number } } // 加载状态(分模式管理) loading: { popularKeywords: false, hotEvents: false, verticalEvents: false, fourRowEvents: false }, // 错误信息(分模式管理) error: { popularKeywords: null, hotEvents: null, verticalEvents: null, fourRowEvents: null } }, reducers: { /** * 清除所有缓存(Redux + localStorage) * 注意:verticalEvents 和 fourRowEvents 不使用 localStorage 缓存 */ clearCache: (state) => { // 清除 localStorage localCacheManager.removeMultiple(Object.values(CACHE_KEYS)); // 清除 Redux 状态 state.popularKeywords = []; state.hotEvents = []; // 清除动态新闻数据(两个模式) state.verticalEventsByPage = {}; state.fourRowEvents = []; state.verticalPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 10 }; state.fourRowPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 30 }; logger.info('CommunityData', '所有缓存已清除'); }, /** * 清除指定类型的缓存 * @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents' | 'verticalEvents' | 'fourRowEvents') */ clearSpecificCache: (state, action) => { const type = action.payload; if (type === 'popularKeywords') { localCacheManager.remove(CACHE_KEYS.POPULAR_KEYWORDS); state.popularKeywords = []; logger.info('CommunityData', '热门关键词缓存已清除'); } else if (type === 'hotEvents') { localCacheManager.remove(CACHE_KEYS.HOT_EVENTS); state.hotEvents = []; logger.info('CommunityData', '热点事件缓存已清除'); } else if (type === 'verticalEvents') { // verticalEvents 不使用 localStorage,只清除 Redux state state.verticalEventsByPage = {}; state.verticalPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 10 }; logger.info('CommunityData', '纵向模式事件数据已清除'); } else if (type === 'fourRowEvents') { // fourRowEvents 不使用 localStorage,只清除 Redux state state.fourRowEvents = []; state.fourRowPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 30 }; logger.info('CommunityData', '平铺模式事件数据已清除'); } }, /** * 预加载数据(用于应用启动时) * 注意:这不是异步 action,只是触发标记 */ preloadData: (_state) => { logger.info('CommunityData', '准备预加载数据'); // 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等 }, /** * 设置单个事件的关注状态(同步) * @param {Object} action.payload - { eventId, isFollowing, followerCount } */ setEventFollowStatus: (state, action) => { const { eventId, isFollowing, followerCount } = action.payload; state.eventFollowStatus[eventId] = { isFollowing, followerCount }; logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount }); }, /** * 更新分页页码(用于缓存场景,无需 API 请求) * @param {Object} action.payload - { mode, page } */ updatePaginationPage: (state, action) => { const { mode, page } = action.payload; const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination'; state[paginationKey].current_page = page; logger.debug('CommunityData', '同步更新分页页码(缓存场景)', { mode, page }); } }, extraReducers: (builder) => { // 使用工厂函数创建 reducers,消除重复代码 createDataReducers(builder, fetchPopularKeywords, 'popularKeywords'); createDataReducers(builder, fetchHotEvents, 'hotEvents'); // dynamicNews 需要特殊处理(缓存 + 追加模式) // 根据 mode 更新不同的 state(verticalEvents 或 fourRowEvents) builder .addCase(fetchDynamicNews.pending, (state, action) => { const mode = action.meta.arg.mode || 'vertical'; const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents'; state.loading[stateKey] = true; state.error[stateKey] = null; }) .addCase(fetchDynamicNews.fulfilled, (state, action) => { const { mode, events, total, page, per_page, clearCache, prependMode, isEmpty } = action.payload; const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents'; const totalKey = mode === 'four-row' ? 'fourRowTotal' : 'verticalTotal'; // 边界条件:空数据只记录日志,不更新 state(保留现有数据) if (isEmpty || (events.length === 0 && !clearCache)) { logger.info('CommunityData', `${mode} 模式返回空数据,跳过更新`); state.loading[stateKey] = false; state.error[stateKey] = '暂无更多数据'; // 设置提示信息供组件显示 toast return; // 提前返回,不更新数据 } // 🔍 调试:收到数据 console.log('%c[Redux] fetchDynamicNews.fulfilled 收到数据', 'color: #10B981; font-weight: bold;', { mode, stateKey, eventsCount: events.length, total, page, clearCache, prependMode, 'state[stateKey] 类型': Array.isArray(state[stateKey]) ? 'Array' : 'Object', 'state[stateKey] 之前': Array.isArray(state[stateKey]) ? `数组长度: ${state[stateKey].length}` : `对象页数: ${Object.keys(state[stateKey] || {}).length}`, }); /** * 【数据存储逻辑】根据模式选择不同的存储策略 * * 纵向模式(vertical):页码映射存储 * - clearCache=true: 清空所有页,存储新页(第1页专用) * - clearCache=false: 存储到对应页码(第2、3、4...页) * - 优点:每页独立,不受去重影响,支持缓存 * * 平铺模式(four-row):去重追加存储 * - clearCache=true: 直接替换(用于刷新) * - prependMode=true: 去重后插入头部(定时刷新) * - 默认:去重后追加到末尾(无限滚动) * - 优点:累积显示,支持虚拟滚动 */ if (mode === 'vertical') { // 【纵向模式】页码映射存储 if (clearCache) { // 第1页:清空所有页,只保留新页 state.verticalEventsByPage = { [page]: events }; logger.debug('CommunityData', `清空缓存并加载第${page}页 (纵向模式)`, { count: events.length }); console.log('%c[Redux] 纵向模式 clearCache,清空所有页', 'color: #10B981; font-weight: bold;', { page, eventsCount: events.length }); } else { // 其他页:存储到对应页码 state.verticalEventsByPage = state.verticalEventsByPage || {}; state.verticalEventsByPage[page] = events; logger.debug('CommunityData', `存储第${page}页数据 (纵向模式)`, { page, count: events.length, totalPages: Object.keys(state.verticalEventsByPage || {}).length }); console.log('%c[Redux] 纵向模式追加页面', 'color: #10B981; font-weight: bold;', { page, eventsCount: events.length, cachedPages: Object.keys(state.verticalEventsByPage || {}) }); } } else if (mode === 'four-row') { // 【平铺模式】去重追加存储 if (clearCache) { // 清空缓存模式:直接替换 state.fourRowEvents = events; logger.debug('CommunityData', `清空缓存并加载新数据 (平铺模式)`, { count: events.length }); console.log('%c[Redux] 平铺模式 clearCache,直接替换数据', 'color: #10B981; font-weight: bold;', { eventsCount: events.length }); } else if (prependMode) { // 追加到头部模式(用于定时刷新):去重后插入头部 const existingIds = new Set((state.fourRowEvents || []).map(e => e.id)); const newEvents = events.filter(e => !existingIds.has(e.id)); state.fourRowEvents = [...newEvents, ...(state.fourRowEvents || [])]; logger.debug('CommunityData', `追加新数据到头部 (平铺模式)`, { newCount: newEvents.length, totalCount: state.fourRowEvents.length }); } else { // 默认追加模式:去重后追加到末尾(用于虚拟滚动加载下一页) const existingIds = new Set((state.fourRowEvents || []).map(e => e.id)); const newEvents = events.filter(e => !existingIds.has(e.id)); state.fourRowEvents = [...(state.fourRowEvents || []), ...newEvents]; logger.debug('CommunityData', `追加新数据(去重,平铺模式)`, { page, originalEventsCount: events.length, newEventsCount: newEvents.length, filteredCount: events.length - newEvents.length, totalCount: state.fourRowEvents.length }); } } // 【元数据存储】存储完整的 pagination 对象 const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination'; const finalPerPage = per_page || (mode === 'four-row' ? 30 : 10); // 兜底默认值 state[paginationKey] = { total: total, total_pages: action.payload.totalPages || Math.ceil(total / finalPerPage), current_page: page, per_page: finalPerPage }; console.log('%c[Redux] 更新分页元数据', 'color: #8B5CF6; font-weight: bold;', { mode, pagination: state[paginationKey] }); state.loading[stateKey] = false; }) .addCase(fetchDynamicNews.rejected, (state, action) => { const mode = action.meta.arg.mode || 'vertical'; const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents'; state.loading[stateKey] = false; state.error[stateKey] = action.payload; logger.error('CommunityData', `${stateKey} 加载失败`, new Error(action.payload)); }) // ===== toggleEventFollow(乐观更新)===== // pending: 立即切换状态 .addCase(toggleEventFollow.pending, (state, action) => { const eventId = action.meta.arg; const current = state.eventFollowStatus[eventId]; // 乐观切换:如果当前已关注则变为未关注,反之亦然 state.eventFollowStatus[eventId] = { isFollowing: !(current?.isFollowing), followerCount: current?.followerCount ?? 0 }; logger.debug('CommunityData', 'toggleEventFollow pending (乐观更新)', { eventId, newIsFollowing: !(current?.isFollowing) }); }) // rejected: 回滚状态 .addCase(toggleEventFollow.rejected, (state, action) => { const eventId = action.meta.arg; const current = state.eventFollowStatus[eventId]; // 回滚:恢复到之前的状态(再次切换回去) state.eventFollowStatus[eventId] = { isFollowing: !(current?.isFollowing), followerCount: current?.followerCount ?? 0 }; logger.error('CommunityData', 'toggleEventFollow rejected (已回滚)', { eventId, error: action.payload }); }) // fulfilled: 使用 API 返回的准确数据覆盖 .addCase(toggleEventFollow.fulfilled, (state, action) => { const { eventId, isFollowing, followerCount } = action.payload; state.eventFollowStatus[eventId] = { isFollowing, followerCount }; logger.debug('CommunityData', 'toggleEventFollow fulfilled', { eventId, isFollowing, followerCount }); }); } }); // ==================== 导出 ==================== export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions; // 基础选择器(Selectors) export const selectPopularKeywords = (state) => state.communityData.popularKeywords; export const selectHotEvents = (state) => state.communityData.hotEvents; export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus; export const selectLoading = (state) => state.communityData.loading; export const selectError = (state) => state.communityData.error; // 纵向模式数据选择器 export const selectVerticalEventsByPage = (state) => state.communityData.verticalEventsByPage; export const selectVerticalPagination = (state) => state.communityData.verticalPagination; export const selectVerticalCachedPageCount = (state) => Object.keys(state.communityData.verticalEventsByPage || {}).length; // 平铺模式数据选择器 export const selectFourRowEvents = (state) => state.communityData.fourRowEvents; export const selectFourRowPagination = (state) => state.communityData.fourRowPagination; export const selectFourRowCachedCount = (state) => (state.communityData.fourRowEvents || []).length; // 向后兼容的选择器(已废弃,建议使用 selectVerticalPagination.total) export const selectVerticalTotal = (state) => state.communityData.verticalPagination?.total || 0; export const selectFourRowTotal = (state) => state.communityData.fourRowPagination?.total || 0; // 组合选择器 export const selectPopularKeywordsWithLoading = (state) => ({ data: state.communityData.popularKeywords, loading: state.communityData.loading.popularKeywords, error: state.communityData.error.popularKeywords }); export const selectHotEventsWithLoading = (state) => ({ data: state.communityData.hotEvents, loading: state.communityData.loading.hotEvents, error: state.communityData.error.hotEvents }); // 纵向模式数据 + 加载状态选择器 export const selectVerticalEventsWithLoading = (state) => ({ data: state.communityData.verticalEventsByPage, // 页码映射 { 1: [...], 2: [...] } loading: state.communityData.loading.verticalEvents, error: state.communityData.error.verticalEvents, pagination: state.communityData.verticalPagination, // 完整分页元数据 { total, total_pages, current_page, per_page } total: state.communityData.verticalPagination?.total || 0, // 向后兼容:服务端总数量 cachedPageCount: Object.keys(state.communityData.verticalEventsByPage || {}).length // 已缓存页数 }); // 平铺模式数据 + 加载状态选择器 export const selectFourRowEventsWithLoading = (state) => ({ data: state.communityData.fourRowEvents, // 完整缓存列表 loading: state.communityData.loading.fourRowEvents, error: state.communityData.error.fourRowEvents, pagination: state.communityData.fourRowPagination, // 完整分页元数据 { total, total_pages, current_page, per_page } total: state.communityData.fourRowPagination?.total || 0, // 向后兼容:服务端总数量 cachedCount: (state.communityData.fourRowEvents || []).length // 已缓存有效数量 }); export default communityDataSlice.reducer;