feat: 热门关键词UI调整 数据获取逻辑调整 接入redux

This commit is contained in:
zdl
2025-10-25 18:22:41 +08:00
parent 873adda1fd
commit 094793c022
7 changed files with 557 additions and 111 deletions

18
src/store/index.js Normal file
View File

@@ -0,0 +1,18 @@
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import communityDataReducer from './slices/communityDataSlice';
export const store = configureStore({
reducer: {
communityData: communityDataReducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// 忽略这些 action types 的序列化检查
ignoredActions: ['communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled'],
},
}),
});
export default store;

View File

@@ -0,0 +1,321 @@
// src/store/slices/communityDataSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { eventService } from '../../services/eventService';
import { logger } from '../../utils/logger';
// ==================== 常量定义 ====================
// 缓存键名
const CACHE_KEYS = {
POPULAR_KEYWORDS: 'community_popular_keywords',
HOT_EVENTS: 'community_hot_events'
};
// 缓存有效期类型
const CACHE_EXPIRY_TYPE = {
MIDNIGHT: 'midnight', // 当天午夜过期
HOURS: 'hours' // 指定小时数后过期
};
// ==================== 缓存工具函数 ====================
/**
* 计算缓存过期时间
* @param {string} type - 过期类型 (midnight/hours)
* @param {number} hours - 小时数(仅当 type 为 hours 时使用)
* @returns {string} ISO 格式的过期时间
*/
const getExpireTime = (type = CACHE_EXPIRY_TYPE.MIDNIGHT, hours = 24) => {
const expireDate = new Date();
if (type === CACHE_EXPIRY_TYPE.MIDNIGHT) {
// 设置为明天凌晨 0 点
expireDate.setDate(expireDate.getDate() + 1);
expireDate.setHours(0, 0, 0, 0);
} else {
// 设置为指定小时后
expireDate.setHours(expireDate.getHours() + hours);
}
return expireDate.toISOString();
};
/**
* 从 localStorage 获取缓存数据
* @param {string} key - 缓存键名
* @returns {any|null} 缓存的数据或 null
*/
const getCachedData = (key) => {
try {
const cached = localStorage.getItem(key);
if (!cached) return null;
const { data, expireAt } = JSON.parse(cached);
// 检查是否过期
if (new Date() > new Date(expireAt)) {
localStorage.removeItem(key);
logger.debug('CommunityData', '缓存已过期', { key });
return null;
}
logger.debug('CommunityData', '使用缓存数据', {
key,
dataLength: Array.isArray(data) ? data.length : 'N/A',
expireAt
});
return data;
} catch (error) {
logger.error('CommunityData', 'getCachedData 失败', error, { key });
// 清除损坏的缓存
localStorage.removeItem(key);
return null;
}
};
/**
* 保存数据到 localStorage
* @param {string} key - 缓存键名
* @param {any} data - 要缓存的数据
* @param {string} expiryType - 过期类型
*/
const setCachedData = (key, data, expiryType = CACHE_EXPIRY_TYPE.MIDNIGHT) => {
try {
const cacheData = {
data,
expireAt: getExpireTime(expiryType),
cachedAt: new Date().toISOString()
};
localStorage.setItem(key, JSON.stringify(cacheData));
logger.debug('CommunityData', '数据已缓存', {
key,
dataLength: Array.isArray(data) ? data.length : 'N/A',
expireAt: cacheData.expireAt,
cachedAt: cacheData.cachedAt
});
} catch (error) {
logger.error('CommunityData', 'setCachedData 失败', error, { key });
// localStorage 可能已满,尝试清理
if (error.name === 'QuotaExceededError') {
logger.warn('CommunityData', 'localStorage 配额已满,尝试清理旧缓存');
Object.values(CACHE_KEYS).forEach(cacheKey => {
localStorage.removeItem(cacheKey);
});
}
}
};
// ==================== 通用数据获取逻辑 ====================
/**
* 通用的数据获取函数支持三级缓存Redux -> localStorage -> API
* @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
}) => {
try {
// 第一级缓存:检查 Redux 状态(除非强制刷新)
if (!forceRefresh) {
const stateData = getState().communityData[stateKey];
if (stateData && stateData.length > 0) {
logger.debug('CommunityData', `Redux 状态中已有${stateKey}数据`);
return stateData;
}
// 第二级缓存:检查 localStorage
const cachedData = getCachedData(cacheKey);
if (cachedData) {
return cachedData;
}
}
// 第三级:从 API 获取
logger.debug('CommunityData', `从 API 获取${stateKey}`, { forceRefresh });
const response = await fetchFn();
if (response.success && response.data) {
// 保存到 localStorage
setCachedData(cacheKey, response.data);
return response.data;
}
logger.warn('CommunityData', `API 返回数据为空:${stateKey}`);
return [];
} catch (error) {
logger.error('CommunityData', `获取${stateKey}失败`, error);
throw error;
}
};
// ==================== 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: 4 }),
getState,
stateKey: 'hotEvents',
forceRefresh
});
} catch (error) {
return rejectWithValue(error.message || '获取热点事件失败');
}
}
);
// ==================== Slice 定义 ====================
const communityDataSlice = createSlice({
name: 'communityData',
initialState: {
// 数据
popularKeywords: [],
hotEvents: [],
// 加载状态
loading: {
popularKeywords: false,
hotEvents: false
},
// 错误信息
error: {
popularKeywords: null,
hotEvents: null
},
// 最后更新时间
lastUpdated: {
popularKeywords: null,
hotEvents: null
}
},
reducers: {
/**
* 清除所有缓存Redux + localStorage
*/
clearCache: (state) => {
// 清除 localStorage
Object.values(CACHE_KEYS).forEach(key => {
localStorage.removeItem(key);
});
// 清除 Redux 状态
state.popularKeywords = [];
state.hotEvents = [];
state.lastUpdated.popularKeywords = null;
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '所有缓存已清除');
},
/**
* 清除指定类型的缓存
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents')
*/
clearSpecificCache: (state, action) => {
const type = action.payload;
if (type === 'popularKeywords') {
localStorage.removeItem(CACHE_KEYS.POPULAR_KEYWORDS);
state.popularKeywords = [];
state.lastUpdated.popularKeywords = null;
logger.info('CommunityData', '热门关键词缓存已清除');
} else if (type === 'hotEvents') {
localStorage.removeItem(CACHE_KEYS.HOT_EVENTS);
state.hotEvents = [];
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '热点事件缓存已清除');
}
}
},
extraReducers: (builder) => {
// ===== 热门关键词 =====
builder
.addCase(fetchPopularKeywords.pending, (state) => {
state.loading.popularKeywords = true;
state.error.popularKeywords = null;
})
.addCase(fetchPopularKeywords.fulfilled, (state, action) => {
state.loading.popularKeywords = false;
state.popularKeywords = action.payload;
state.lastUpdated.popularKeywords = new Date().toISOString();
})
.addCase(fetchPopularKeywords.rejected, (state, action) => {
state.loading.popularKeywords = false;
state.error.popularKeywords = action.payload;
logger.error('CommunityData', '热门关键词加载失败', new Error(action.payload));
});
// ===== 热点事件 =====
builder
.addCase(fetchHotEvents.pending, (state) => {
state.loading.hotEvents = true;
state.error.hotEvents = null;
})
.addCase(fetchHotEvents.fulfilled, (state, action) => {
state.loading.hotEvents = false;
state.hotEvents = action.payload;
state.lastUpdated.hotEvents = new Date().toISOString();
})
.addCase(fetchHotEvents.rejected, (state, action) => {
state.loading.hotEvents = false;
state.error.hotEvents = action.payload;
logger.error('CommunityData', '热点事件加载失败', new Error(action.payload));
});
}
});
// ==================== 导出 ====================
export const { clearCache, clearSpecificCache } = communityDataSlice.actions;
// 选择器Selectors
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
export const selectHotEvents = (state) => state.communityData.hotEvents;
export const selectLoading = (state) => state.communityData.loading;
export const selectError = (state) => state.communityData.error;
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
export default communityDataSlice.reducer;