Files
vf_react/src/store/slices/communityDataSlice.js
zdl 023684b8b7 feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步
1. communityDataSlice 添加事件关注乐观更新
   - pending: 立即切换 isFollowing 状态
   - rejected: 回滚到之前状态
   - fulfilled: 使用 API 返回的准确数据覆盖

2. Mock 数据添加内存状态管理
   - 新增 followedEventsSet 和 followedEventsMap 存储
   - toggleEventFollowStatus: 切换关注状态
   - isEventFollowed: 检查是否已关注
   - getFollowedEvents: 获取关注事件列表

3. Mock handlers 使用内存状态
   - follow handler: 使用 toggleEventFollowStatus
   - following handler: 使用 getFollowedEvents 动态返回
   - 事件详情: 返回正确的 is_following 状态

修复: 关注事件后导航栏"自选事件"列表不同步更新的问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 16:34:36 +08:00

708 lines
28 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/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<any>} 获取的数据
*/
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 更新不同的 stateverticalEvents 或 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;