feat(EventDailyStats): 添加日期选择功能,与 MarketOverviewBanner 联动

- EventDailyStats 添加日期选择器,点击日期文字弹出选择器
- 使用 Redux 共享 effectivenessStats 数据,避免重复请求
- 两个组件日期同步联动
- 仅选择今天时启用自动刷新(60秒)
- 修复 useColorModeValue Hook 规则违规(DynamicNewsEventCard、CompactEventCard)
- 添加 createSelector 优化 Redux 选择器
- 删除 EventStatsContext(被 Redux 替代)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2026-01-15 19:05:47 +08:00
parent d4a129c121
commit 6806df90c9
6 changed files with 297 additions and 274 deletions

View File

@@ -1,5 +1,5 @@
// src/store/slices/communityDataSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit';
import { eventService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
@@ -10,7 +10,8 @@ import { getApiBase } from '../../utils/apiConfig';
// 缓存键名
const CACHE_KEYS = {
POPULAR_KEYWORDS: 'community_popular_keywords',
HOT_EVENTS: 'community_hot_events'
HOT_EVENTS: 'community_hot_events',
EFFECTIVENESS_STATS: 'community_effectiveness_stats'
};
// 请求去重:缓存正在进行的请求
@@ -156,6 +157,44 @@ export const fetchHotEvents = createAsyncThunk(
}
);
/**
* 获取事件效果统计EventDailyStats + MarketOverviewBanner 共享)
* @param {Object} params - 请求参数
* @param {string} params.date - 日期(可选,默认今天)
* @param {boolean} params.forceRefresh - 是否强制刷新
*/
export const fetchEffectivenessStats = createAsyncThunk(
'communityData/fetchEffectivenessStats',
async ({ date = '', forceRefresh = false } = {}, { getState, rejectWithValue }) => {
try {
const apiBase = getApiBase();
const dateParam = date ? `&date=${date}` : '';
// 检查缓存(同日期才复用)
const state = getState().communityData;
if (!forceRefresh && state.effectivenessStats && state.effectivenessStatsDate === date) {
logger.debug('CommunityData', '复用已缓存的 effectivenessStats', { date });
return { data: state.effectivenessStats, date, fromCache: true };
}
const response = await fetch(
`${apiBase}/api/v1/events/effectiveness-stats?days=1${dateParam}`
);
if (!response.ok) throw new Error('获取数据失败');
const result = await response.json();
if (result.success || result.code === 200) {
logger.info('CommunityData', '获取 effectivenessStats 成功', { date });
return { data: result.data, date, fromCache: false };
}
throw new Error(result.message || '数据格式错误');
} catch (error) {
logger.error('CommunityData', '获取 effectivenessStats 失败', error);
return rejectWithValue(error.message || '获取事件效果统计失败');
}
}
);
/**
* 获取动态新闻(客户端缓存 + 虚拟滚动)
* 用于 DynamicNewsCard 组件
@@ -348,6 +387,10 @@ const communityDataSlice = createSlice({
popularKeywords: [],
hotEvents: [],
// 【事件效果统计】EventDailyStats + MarketOverviewBanner 共享
effectivenessStats: null, // API 返回的完整数据
effectivenessStatsDate: '', // 当前数据对应的日期
// 【纵向模式】独立存储(传统分页 + 每页10条
verticalEventsByPage: {}, // 页码映射存储 { 1: [10条], 2: [8条], 3: [10条] }
verticalPagination: { // 分页元数据
@@ -372,6 +415,7 @@ const communityDataSlice = createSlice({
loading: {
popularKeywords: false,
hotEvents: false,
effectivenessStats: false,
verticalEvents: false,
fourRowEvents: false
},
@@ -380,6 +424,7 @@ const communityDataSlice = createSlice({
error: {
popularKeywords: null,
hotEvents: null,
effectivenessStats: null,
verticalEvents: null,
fourRowEvents: null
}
@@ -471,6 +516,26 @@ const communityDataSlice = createSlice({
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
createDataReducers(builder, fetchHotEvents, 'hotEvents');
// effectivenessStats 特殊处理(带日期缓存)
builder
.addCase(fetchEffectivenessStats.pending, (state) => {
state.loading.effectivenessStats = true;
state.error.effectivenessStats = null;
})
.addCase(fetchEffectivenessStats.fulfilled, (state, action) => {
const { data, date, fromCache } = action.payload;
if (!fromCache) {
state.effectivenessStats = data;
state.effectivenessStatsDate = date;
}
state.loading.effectivenessStats = false;
})
.addCase(fetchEffectivenessStats.rejected, (state, action) => {
state.loading.effectivenessStats = false;
state.error.effectivenessStats = action.payload;
logger.error('CommunityData', 'effectivenessStats 加载失败', new Error(action.payload));
});
// dynamicNews 需要特殊处理(缓存 + 追加模式)
// 根据 mode 更新不同的 stateverticalEvents 或 fourRowEvents
builder
@@ -651,13 +716,18 @@ const communityDataSlice = createSlice({
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions;
// 基础选择器Selectors
// ==================== 基础选择器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;
// effectivenessStats 基础选择器
export const selectEffectivenessStats = (state) => state.communityData.effectivenessStats;
export const selectEffectivenessStatsDate = (state) => state.communityData.effectivenessStatsDate;
export const selectEffectivenessStatsLoading = (state) => state.communityData.loading.effectivenessStats;
// 纵向模式数据选择器
export const selectVerticalEventsByPage = (state) => state.communityData.verticalEventsByPage;
export const selectVerticalPagination = (state) => state.communityData.verticalPagination;
@@ -705,4 +775,58 @@ export const selectFourRowEventsWithLoading = (state) => ({
cachedCount: (state.communityData.fourRowEvents || []).length // 已缓存有效数量
});
// ==================== Memoized Selectors (createSelector) ====================
// 使用 createSelector 避免不必要的重渲染
const selectCommunityData = (state) => state.communityData;
/**
* effectivenessStats 完整数据选择器(带加载状态)
* 使用 createSelector 记忆化,仅当依赖数据变化时才重新计算
*/
export const selectEffectivenessStatsWithLoading = createSelector(
[selectCommunityData],
(communityData) => ({
stats: communityData.effectivenessStats,
loading: communityData.loading.effectivenessStats,
error: communityData.error.effectivenessStats,
date: communityData.effectivenessStatsDate,
// 派生数据(仅在 stats 变化时重新计算)
summary: communityData.effectivenessStats?.summary || null,
marketStats: communityData.effectivenessStats?.marketStats || null,
topPerformers: communityData.effectivenessStats?.topPerformers || [],
topStocks: communityData.effectivenessStats?.topStocks || [],
})
);
/**
* 纵向模式事件选择器(记忆化)
*/
export const selectVerticalEventsWithLoadingMemo = createSelector(
[selectCommunityData],
(communityData) => ({
data: communityData.verticalEventsByPage,
loading: communityData.loading.verticalEvents,
error: communityData.error.verticalEvents,
pagination: communityData.verticalPagination,
total: communityData.verticalPagination?.total || 0,
cachedPageCount: Object.keys(communityData.verticalEventsByPage || {}).length
})
);
/**
* 平铺模式事件选择器(记忆化)
*/
export const selectFourRowEventsWithLoadingMemo = createSelector(
[selectCommunityData],
(communityData) => ({
data: communityData.fourRowEvents,
loading: communityData.loading.fourRowEvents,
error: communityData.error.fourRowEvents,
pagination: communityData.fourRowPagination,
total: communityData.fourRowPagination?.total || 0,
cachedCount: (communityData.fourRowEvents || []).length
})
);
export default communityDataSlice.reducer;

View File

@@ -49,7 +49,9 @@ const CompactEventCard = ({
borderColor,
}) => {
const importance = getImportanceConfig(event.importance);
// 所有 useColorModeValue 必须在组件顶层调用
const cardBg = useColorModeValue('white', 'gray.800');
const cardBgAlt = useColorModeValue('gray.50', 'gray.750');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
@@ -71,7 +73,7 @@ const CompactEventCard = ({
{/* 右侧内容卡片 */}
<Card
flex="1"
bg={index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750')}
bg={index % 2 === 0 ? cardBg : cardBgAlt}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"

View File

@@ -44,8 +44,51 @@ const DynamicNewsEventCard = React.memo(({
onToggleFollow,
borderColor,
}) => {
// ========== 所有 useColorModeValue 必须在组件顶层调用 ==========
const cardBg = useColorModeValue('white', 'gray.800');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const selectedBg = useColorModeValue('blue.50', 'blue.900');
const selectedBorderColor = useColorModeValue('blue.500', 'blue.400');
const defaultBg = useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
// 时间标签颜色(按交易时段)
const timeLabelColors = {
preMarket: {
bg: useColorModeValue('pink.50', 'pink.900'),
border: useColorModeValue('pink.300', 'pink.500'),
text: useColorModeValue('pink.600', 'pink.300'),
},
trading: {
bg: useColorModeValue('red.50', 'red.900'),
border: useColorModeValue('red.400', 'red.500'),
text: useColorModeValue('red.700', 'red.300'),
},
lunchBreak: {
bg: useColorModeValue('gray.100', 'gray.800'),
border: useColorModeValue('gray.400', 'gray.500'),
text: useColorModeValue('gray.600', 'gray.400'),
},
afterMarket: {
bg: useColorModeValue('orange.50', 'orange.900'),
border: useColorModeValue('orange.400', 'orange.500'),
text: useColorModeValue('orange.600', 'orange.300'),
},
};
// 涨跌幅背景色(按级别)
const changeBgColors = {
up9: useColorModeValue('rgba(254, 202, 202, 0.9)', 'rgba(127, 29, 29, 0.9)'),
up7: useColorModeValue('rgba(254, 202, 202, 0.8)', 'rgba(153, 27, 27, 0.8)'),
up5: useColorModeValue('rgba(254, 226, 226, 0.8)', 'rgba(185, 28, 28, 0.8)'),
up3: useColorModeValue('rgba(254, 226, 226, 0.7)', 'rgba(220, 38, 38, 0.7)'),
up0: useColorModeValue('rgba(254, 242, 242, 0.7)', 'rgba(239, 68, 68, 0.7)'),
down9: useColorModeValue('rgba(187, 247, 208, 0.9)', 'rgba(20, 83, 45, 0.9)'),
down7: useColorModeValue('rgba(187, 247, 208, 0.8)', 'rgba(22, 101, 52, 0.8)'),
down5: useColorModeValue('rgba(209, 250, 229, 0.8)', 'rgba(21, 128, 61, 0.8)'),
down3: useColorModeValue('rgba(209, 250, 229, 0.7)', 'rgba(22, 163, 74, 0.7)'),
down0: useColorModeValue('rgba(240, 253, 244, 0.7)', 'rgba(34, 197, 94, 0.7)'),
};
const importance = getImportanceConfig(event.importance);
/**
@@ -88,46 +131,42 @@ const DynamicNewsEventCard = React.memo(({
};
/**
* 获取时间标签样式(根据交易时段)
* 获取时间标签样式(根据交易时段)- 使用预计算的颜色值
* @param {string} period - 交易时段
* @returns {Object} Chakra UI 样式对象
*/
const getTimeLabelStyle = (period) => {
switch (period) {
case 'pre-market':
// 盘前:粉红色系(浅红)
return {
bg: useColorModeValue('pink.50', 'pink.900'),
borderColor: useColorModeValue('pink.300', 'pink.500'),
textColor: useColorModeValue('pink.600', 'pink.300'),
bg: timeLabelColors.preMarket.bg,
borderColor: timeLabelColors.preMarket.border,
textColor: timeLabelColors.preMarket.text,
};
case 'morning-trading':
case 'afternoon-trading':
// 盘中:红色系(强烈,表示交易活跃)
return {
bg: useColorModeValue('red.50', 'red.900'),
borderColor: useColorModeValue('red.400', 'red.500'),
textColor: useColorModeValue('red.700', 'red.300'),
bg: timeLabelColors.trading.bg,
borderColor: timeLabelColors.trading.border,
textColor: timeLabelColors.trading.text,
};
case 'lunch-break':
// 午休:灰色系(中性)
return {
bg: useColorModeValue('gray.100', 'gray.800'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
textColor: useColorModeValue('gray.600', 'gray.400'),
bg: timeLabelColors.lunchBreak.bg,
borderColor: timeLabelColors.lunchBreak.border,
textColor: timeLabelColors.lunchBreak.text,
};
case 'after-market':
// 盘后:橙色系(暖色但区别于盘中红色)
return {
bg: useColorModeValue('orange.50', 'orange.900'),
borderColor: useColorModeValue('orange.400', 'orange.500'),
textColor: useColorModeValue('orange.600', 'orange.300'),
bg: timeLabelColors.afterMarket.bg,
borderColor: timeLabelColors.afterMarket.border,
textColor: timeLabelColors.afterMarket.text,
};
default:
return {
bg: useColorModeValue('gray.100', 'gray.800'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
textColor: useColorModeValue('gray.600', 'gray.400'),
bg: timeLabelColors.lunchBreak.bg,
borderColor: timeLabelColors.lunchBreak.border,
textColor: timeLabelColors.lunchBreak.text,
};
}
};
@@ -154,7 +193,7 @@ const DynamicNewsEventCard = React.memo(({
};
/**
* 根据平均涨幅计算背景色(分级策略)- 使用毛玻璃效果
* 根据平均涨幅计算背景色(分级策略)- 使用预计算的颜色值
* @param {number} avgChange - 平均涨跌幅
* @returns {string} Chakra UI 颜色值
*/
@@ -163,28 +202,28 @@ const DynamicNewsEventCard = React.memo(({
// 如果没有涨跌幅数据,使用半透明背景
if (avgChange == null || isNaN(numChange)) {
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
return defaultBg;
}
// 根据涨跌幅分级返回半透明背景色(毛玻璃效果)
const absChange = Math.abs(numChange);
if (numChange > 0) {
// 涨:红色系半透明
if (absChange >= 9) return useColorModeValue('rgba(254, 202, 202, 0.9)', 'rgba(127, 29, 29, 0.9)');
if (absChange >= 7) return useColorModeValue('rgba(254, 202, 202, 0.8)', 'rgba(153, 27, 27, 0.8)');
if (absChange >= 5) return useColorModeValue('rgba(254, 226, 226, 0.8)', 'rgba(185, 28, 28, 0.8)');
if (absChange >= 3) return useColorModeValue('rgba(254, 226, 226, 0.7)', 'rgba(220, 38, 38, 0.7)');
return useColorModeValue('rgba(254, 242, 242, 0.7)', 'rgba(239, 68, 68, 0.7)');
if (absChange >= 9) return changeBgColors.up9;
if (absChange >= 7) return changeBgColors.up7;
if (absChange >= 5) return changeBgColors.up5;
if (absChange >= 3) return changeBgColors.up3;
return changeBgColors.up0;
} else if (numChange < 0) {
// 跌:绿色系半透明
if (absChange >= 9) return useColorModeValue('rgba(187, 247, 208, 0.9)', 'rgba(20, 83, 45, 0.9)');
if (absChange >= 7) return useColorModeValue('rgba(187, 247, 208, 0.8)', 'rgba(22, 101, 52, 0.8)');
if (absChange >= 5) return useColorModeValue('rgba(209, 250, 229, 0.8)', 'rgba(21, 128, 61, 0.8)');
if (absChange >= 3) return useColorModeValue('rgba(209, 250, 229, 0.7)', 'rgba(22, 163, 74, 0.7)');
return useColorModeValue('rgba(240, 253, 244, 0.7)', 'rgba(34, 197, 94, 0.7)');
if (absChange >= 9) return changeBgColors.down9;
if (absChange >= 7) return changeBgColors.down7;
if (absChange >= 5) return changeBgColors.down5;
if (absChange >= 3) return changeBgColors.down3;
return changeBgColors.down0;
}
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
return defaultBg;
};
// 获取当前事件的交易时段、样式和文字标签
@@ -197,16 +236,10 @@ const DynamicNewsEventCard = React.memo(({
{/* 事件卡片 */}
<Card
position="relative"
bg={isSelected
? useColorModeValue('blue.50', 'blue.900')
: getChangeBasedBgColor(event.related_avg_chg)
}
bg={isSelected ? selectedBg : getChangeBasedBgColor(event.related_avg_chg)}
backdropFilter={GLASS_BLUR.sm} // 毛玻璃效果
borderWidth={isSelected ? "2px" : "1px"}
borderColor={isSelected
? useColorModeValue('blue.500', 'blue.400')
: borderColor
}
borderColor={isSelected ? selectedBorderColor : borderColor}
borderRadius="lg"
boxShadow={isSelected ? "xl" : "md"}
overflow="visible"

View File

@@ -1,8 +1,11 @@
/**
* EventDailyStats - 事件 TOP 排行面板
* 展示当日事件的表现排行
*
* 【优化】使用 Redux 共享 effectivenessStats 数据
* 避免与 MarketOverviewBanner 重复请求
*/
import React, { useState, useEffect, useCallback, useMemo, memo, useRef } from "react";
import React, { useEffect, useMemo, memo, useState, useRef } from "react";
import {
Box,
Text,
@@ -12,9 +15,14 @@ import {
Center,
Tooltip,
Badge,
Input,
} from "@chakra-ui/react";
import { motion, useAnimationControls } from "framer-motion";
import { getApiBase } from "@utils/apiConfig";
import { useSelector, useDispatch } from "react-redux";
import {
fetchEffectivenessStats,
selectEffectivenessStatsWithLoading,
} from "@store/slices/communityDataSlice";
const MotionBox = motion.create(Box);
@@ -104,77 +112,52 @@ const ITEM_HEIGHT = 32;
const VISIBLE_COUNT = 8;
const EventDailyStats = () => {
const [loading, setLoading] = useState(true);
const [, setRefreshing] = useState(false);
const [stats, setStats] = useState(null);
const [error, setError] = useState(null);
const dispatch = useDispatch();
const { loading, error, topPerformers, date: selectedDate } = useSelector(selectEffectivenessStatsWithLoading);
const [isPaused, setIsPaused] = useState(false);
const controls = useAnimationControls();
const dateInputRef = useRef(null);
// 用于数据对比,避免相同数据触发重渲染
const prevDataHashRef = useRef(null);
// 今天的日期(用于限制选择范围和判断是否自动刷新)
const today = new Date().toISOString().split("T")[0];
const fetchStats = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const apiBase = getApiBase();
const response = await fetch(
`${apiBase}/api/v1/events/effectiveness-stats?days=1`
);
if (!response.ok) throw new Error("获取数据失败");
const data = await response.json();
if (data.success || data.code === 200) {
// 生成数据指纹用于对比
const topPerformers = data.data?.topPerformers || [];
const dataHash = topPerformers
.slice(0, 10)
.map((e) => `${e.id}-${e.avgChg}`)
.join("|");
// 仅当数据真正变化时才更新状态
if (dataHash !== prevDataHashRef.current) {
prevDataHashRef.current = dataHash;
setStats(data.data);
}
} else {
throw new Error(data.message || "数据格式错误");
}
} catch (err) {
console.error("获取事件统计失败:", err);
setError(err.message);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// 当前有效日期(首次加载时 Redux 中的 date 可能为空,默认用今天)
const effectiveDate = selectedDate || today;
// 首次加载:获取今天的数据(与 MarketOverviewBanner 共享)
useEffect(() => {
fetchStats();
}, [fetchStats]);
dispatch(fetchEffectivenessStats({ date: today, forceRefresh: false }));
}, [dispatch, today]);
// 自动刷新每60秒刷新一次
// 自动刷新:仅当选择今天时启用每60秒
useEffect(() => {
const interval = setInterval(() => fetchStats(true), 60 * 1000);
if (effectiveDate !== today) return;
const interval = setInterval(() => {
dispatch(fetchEffectivenessStats({ date: today, forceRefresh: true }));
}, 60 * 1000);
return () => clearInterval(interval);
}, [fetchStats]);
}, [dispatch, effectiveDate, today]);
// 日期变化处理
const handleDateChange = (e) => {
const newDate = e.target.value;
if (newDate) {
dispatch(fetchEffectivenessStats({ date: newDate, forceRefresh: true }));
}
};
// 获取显示列表取前10个复制一份用于无缝循环
const displayList = useMemo(() => {
const topPerformers = stats?.topPerformers || [];
const list = topPerformers.slice(0, 10);
// 数据不足5个时不需要滚动
const list = (topPerformers || []).slice(0, 10);
// 数据不足8个时不需要滚动
if (list.length <= VISIBLE_COUNT) return list;
// 复制一份用于无缝循环
return [...list, ...list];
}, [stats]);
}, [topPerformers]);
const needScroll = displayList.length > VISIBLE_COUNT;
const originalCount = Math.min((stats?.topPerformers || []).length, 10);
const originalCount = Math.min((topPerformers || []).length, 10);
const totalScrollHeight = originalCount * ITEM_HEIGHT;
// 滚动动画
@@ -199,7 +182,7 @@ const EventDailyStats = () => {
startAnimation();
}, [needScroll, isPaused, controls, totalScrollHeight, originalCount]);
if (loading) {
if (loading && !topPerformers?.length) {
return (
<Box
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
@@ -216,7 +199,7 @@ const EventDailyStats = () => {
);
}
const hasData = stats && displayList.length > 0;
const hasData = displayList.length > 0;
return (
<Box
@@ -267,6 +250,32 @@ const EventDailyStats = () => {
<Text fontSize="sm" fontWeight="bold" color="white">
事件 TOP 排行
</Text>
{/* 日期显示 - 点击触发隐藏的日期选择器 */}
<Box position="relative">
<Text
fontSize="xs"
color="gray.400"
cursor="pointer"
_hover={{ color: "gray.200" }}
onClick={() => dateInputRef.current?.showPicker?.()}
>
{effectiveDate}
</Text>
<Input
ref={dateInputRef}
type="date"
value={effectiveDate}
onChange={handleDateChange}
max={today}
position="absolute"
opacity={0}
w={0}
h={0}
p={0}
border="none"
/>
</Box>
</HStack>
{/* 内容区域 - 固定高度显示8个向上滚动轮播 */}

View File

@@ -1,8 +1,11 @@
/**
* MarketOverviewBanner - 市场与事件概览通栏组件
* 顶部通栏展示市场涨跌分布和事件统计数据
*
* 【优化】使用 Redux 共享 effectivenessStats 数据
* 避免与 EventDailyStats 重复请求
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useRef } from "react";
import {
Box,
Text,
@@ -20,7 +23,11 @@ import {
CalendarOutlined,
BarChartOutlined,
} from "@ant-design/icons";
import { getApiBase } from "@utils/apiConfig";
import { useSelector, useDispatch } from "react-redux";
import {
fetchEffectivenessStats,
selectEffectivenessStatsWithLoading,
} from "@store/slices/communityDataSlice";
// 模块化导入
import {
@@ -37,47 +44,26 @@ import {
import StockTop10Modal from "./MarketOverviewBanner/StockTop10Modal";
const MarketOverviewBanner = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState(null);
const dispatch = useDispatch();
const { loading, stats, topStocks, summary, marketStats, date: statsDate } =
useSelector(selectEffectivenessStatsWithLoading);
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split("T")[0]
);
const [stockModalVisible, setStockModalVisible] = useState(false);
const dateInputRef = useRef(null);
const fetchStats = useCallback(async (dateStr = "", showLoading = false) => {
if (showLoading) {
setLoading(true);
}
try {
const apiBase = getApiBase();
const dateParam = dateStr ? `&date=${dateStr}` : "";
const response = await fetch(
`${apiBase}/api/v1/events/effectiveness-stats?days=1${dateParam}`
);
if (!response.ok) throw new Error("获取数据失败");
const data = await response.json();
if (data.success || data.code === 200) {
setStats(data.data);
}
} catch (err) {
console.error("获取市场统计失败:", err);
} finally {
setLoading(false);
}
}, []);
// 首次加载标记
const isInitialMount = useRef(true);
// 首次加载显示 loading
// 首次加载dispatch Redux action
useEffect(() => {
fetchStats(selectedDate, true);
dispatch(fetchEffectivenessStats({ date: selectedDate, forceRefresh: false }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 日期变化时静默刷新(带防抖,避免快速切换时多次请求
// 跳过首次加载,避免重复请求
// 日期变化时刷新(带防抖,跳过首次加载
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
@@ -85,19 +71,22 @@ const MarketOverviewBanner = () => {
}
if (selectedDate) {
const timer = setTimeout(() => {
fetchStats(selectedDate, false);
dispatch(fetchEffectivenessStats({ date: selectedDate, forceRefresh: true }));
}, 300);
return () => clearTimeout(timer);
}
}, [fetchStats, selectedDate]);
}, [dispatch, selectedDate]);
// 自动刷新每60秒仅当选择今天时
useEffect(() => {
if (!selectedDate) {
const interval = setInterval(() => fetchStats(""), 60 * 1000);
const today = new Date().toISOString().split("T")[0];
if (selectedDate === today) {
const interval = setInterval(() => {
dispatch(fetchEffectivenessStats({ date: selectedDate, forceRefresh: true }));
}, 60 * 1000);
return () => clearInterval(interval);
}
}, [selectedDate, fetchStats]);
}, [selectedDate, dispatch]);
const handleDateChange = (e) => {
setSelectedDate(e.target.value);
@@ -107,7 +96,7 @@ const MarketOverviewBanner = () => {
dateInputRef.current?.showPicker?.();
};
if (loading) {
if (loading && !stats) {
return (
<Box h="100px" display="flex" alignItems="center" justifyContent="center">
<Spinner size="sm" color="yellow.400" />
@@ -117,7 +106,6 @@ const MarketOverviewBanner = () => {
if (!stats) return null;
const { summary, marketStats, topStocks = [] } = stats;
const today = new Date().toISOString().split("T")[0];
return (
@@ -152,7 +140,7 @@ const MarketOverviewBanner = () => {
</HStack>
)}
{/* 实时标签 */}
{!selectedDate && (
{selectedDate === today && (
<HStack
spacing={1}
px={2}

View File

@@ -1,133 +0,0 @@
/**
* EventStatsContext - 事件统计数据共享 Context
* 让 EventDailyStats 和 MarketOverviewBanner 共享同一份 API 数据
* 避免重复请求 /api/v1/events/effectiveness-stats
*/
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
} from "react";
import { getApiBase } from "@utils/apiConfig";
const EventStatsContext = createContext(null);
/**
* EventStatsProvider - 事件统计数据提供者
*/
export const EventStatsProvider = ({ children }) => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState(null);
const [error, setError] = useState(null);
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split("T")[0]
);
// 数据指纹用于对比,避免相同数据触发重渲染
const prevDataHashRef = useRef(null);
// 防抖定时器
const debounceTimerRef = useRef(null);
// 首次加载标记
const isInitialMount = useRef(true);
const fetchStats = useCallback(async (dateStr = "", showLoading = false) => {
if (showLoading) {
setLoading(true);
}
setError(null);
try {
const apiBase = getApiBase();
const dateParam = dateStr ? `&date=${dateStr}` : "";
const response = await fetch(
`${apiBase}/api/v1/events/effectiveness-stats?days=1${dateParam}`
);
if (!response.ok) throw new Error("获取数据失败");
const data = await response.json();
if (data.success || data.code === 200) {
// 生成数据指纹用于对比
const topPerformers = data.data?.topPerformers || [];
const dataHash = topPerformers
.slice(0, 10)
.map((e) => `${e.id}-${e.avgChg}`)
.join("|");
// 仅当数据真正变化时才更新状态
if (dataHash !== prevDataHashRef.current) {
prevDataHashRef.current = dataHash;
setStats(data.data);
}
} else {
throw new Error(data.message || "数据格式错误");
}
} catch (err) {
console.error("获取事件统计失败:", err);
setError(err.message);
} finally {
setLoading(false);
}
}, []);
// 首次加载
useEffect(() => {
fetchStats(selectedDate, true);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 日期变化时刷新(带防抖)
// 跳过首次加载,避免重复请求
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
fetchStats(selectedDate, false);
}, 300);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [selectedDate, fetchStats]);
// 自动刷新每60秒
useEffect(() => {
const interval = setInterval(() => fetchStats(selectedDate, false), 60000);
return () => clearInterval(interval);
}, [selectedDate, fetchStats]);
const value = {
loading,
stats,
error,
selectedDate,
setSelectedDate,
refresh: () => fetchStats(selectedDate, false),
};
return (
<EventStatsContext.Provider value={value}>
{children}
</EventStatsContext.Provider>
);
};
/**
* useEventStats - 使用事件统计数据的 Hook
*/
export const useEventStats = () => {
const context = useContext(EventStatsContext);
if (!context) {
throw new Error("useEventStats must be used within EventStatsProvider");
}
return context;
};
export default EventStatsContext;