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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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个,向上滚动轮播 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user