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>
708 lines
28 KiB
JavaScript
708 lines
28 KiB
JavaScript
// 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 更新不同的 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;
|