Compare commits
5 Commits
e110d5860c
...
6806df90c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6806df90c9 | ||
|
|
d4a129c121 | ||
|
|
75fd9924bc | ||
|
|
afdc94049c | ||
|
|
6cf9dca324 |
@@ -18,15 +18,10 @@ import { Box, Text, VStack, Tooltip } from "@chakra-ui/react";
|
|||||||
import { keyframes } from "@emotion/react";
|
import { keyframes } from "@emotion/react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
// 动画定义
|
// 动画定义 - 使用 transform 代替 background-position(GPU 加速)
|
||||||
const shimmer = keyframes`
|
const shimmer = keyframes`
|
||||||
0% { background-position: -200% 0; }
|
0% { transform: translateX(-100%); }
|
||||||
100% { background-position: 200% 0; }
|
100% { transform: translateX(100%); }
|
||||||
`;
|
|
||||||
|
|
||||||
const glow = keyframes`
|
|
||||||
0%, 100% { box-shadow: 0 0 5px rgba(212, 175, 55, 0.3); }
|
|
||||||
50% { box-shadow: 0 0 20px rgba(212, 175, 55, 0.6); }
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -408,12 +403,9 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
|
|||||||
const dateStr = dayjs(date).format("YYYYMMDD");
|
const dateStr = dayjs(date).format("YYYYMMDD");
|
||||||
const dateData = dataMapRef.current.get(dateStr);
|
const dateData = dataMapRef.current.get(dateStr);
|
||||||
|
|
||||||
// 找到 day-top 容器并插入自定义内容
|
// 找到 day-top 容器并插入自定义内容(直接替换,无需先清空)
|
||||||
const dayTop = el.querySelector(".fc-daygrid-day-top");
|
const dayTop = el.querySelector(".fc-daygrid-day-top");
|
||||||
if (dayTop) {
|
if (dayTop) {
|
||||||
// 清空默认内容
|
|
||||||
dayTop.innerHTML = "";
|
|
||||||
// 插入自定义内容
|
|
||||||
dayTop.innerHTML = createCellContentHTML(date, dateData, isToday);
|
dayTop.innerHTML = createCellContentHTML(date, dateData, isToday);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -463,17 +455,17 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
|
|||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
position="relative"
|
position="relative"
|
||||||
>
|
>
|
||||||
{/* 闪光效果 */}
|
{/* 闪光效果 - 使用 transform 实现 GPU 加速 */}
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
top="0"
|
top="0"
|
||||||
left="0"
|
left="0"
|
||||||
right="0"
|
w="100%"
|
||||||
bottom="0"
|
h="100%"
|
||||||
bgGradient="linear(to-r, transparent, rgba(255,255,255,0.3), transparent)"
|
bgGradient="linear(to-r, transparent, rgba(255,255,255,0.4), transparent)"
|
||||||
backgroundSize="200% 100%"
|
animation={`${shimmer} 2.5s ease-in-out infinite`}
|
||||||
animation={`${shimmer} 3s linear infinite`}
|
opacity={0.6}
|
||||||
opacity={0.5}
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
@@ -579,7 +571,8 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
|
|||||||
},
|
},
|
||||||
".fc-daygrid-day.fc-day-today": {
|
".fc-daygrid-day.fc-day-today": {
|
||||||
bg: "rgba(212, 175, 55, 0.15) !important",
|
bg: "rgba(212, 175, 55, 0.15) !important",
|
||||||
animation: `${glow} 2s ease-in-out infinite`,
|
boxShadow: "0 0 15px rgba(212, 175, 55, 0.5)",
|
||||||
|
// 移除呼吸动画,使用固定 boxShadow 高亮"今天",避免内容闪烁
|
||||||
},
|
},
|
||||||
".fc-daygrid-day-frame": {
|
".fc-daygrid-day-frame": {
|
||||||
minHeight: "50px",
|
minHeight: "50px",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/store/slices/communityDataSlice.js
|
// src/store/slices/communityDataSlice.js
|
||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit';
|
||||||
import { eventService } from '../../services/eventService';
|
import { eventService } from '../../services/eventService';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
|
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
|
||||||
@@ -10,7 +10,8 @@ import { getApiBase } from '../../utils/apiConfig';
|
|||||||
// 缓存键名
|
// 缓存键名
|
||||||
const CACHE_KEYS = {
|
const CACHE_KEYS = {
|
||||||
POPULAR_KEYWORDS: 'community_popular_keywords',
|
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 组件
|
* 用于 DynamicNewsCard 组件
|
||||||
@@ -348,6 +387,10 @@ const communityDataSlice = createSlice({
|
|||||||
popularKeywords: [],
|
popularKeywords: [],
|
||||||
hotEvents: [],
|
hotEvents: [],
|
||||||
|
|
||||||
|
// 【事件效果统计】EventDailyStats + MarketOverviewBanner 共享
|
||||||
|
effectivenessStats: null, // API 返回的完整数据
|
||||||
|
effectivenessStatsDate: '', // 当前数据对应的日期
|
||||||
|
|
||||||
// 【纵向模式】独立存储(传统分页 + 每页10条)
|
// 【纵向模式】独立存储(传统分页 + 每页10条)
|
||||||
verticalEventsByPage: {}, // 页码映射存储 { 1: [10条], 2: [8条], 3: [10条] }
|
verticalEventsByPage: {}, // 页码映射存储 { 1: [10条], 2: [8条], 3: [10条] }
|
||||||
verticalPagination: { // 分页元数据
|
verticalPagination: { // 分页元数据
|
||||||
@@ -372,6 +415,7 @@ const communityDataSlice = createSlice({
|
|||||||
loading: {
|
loading: {
|
||||||
popularKeywords: false,
|
popularKeywords: false,
|
||||||
hotEvents: false,
|
hotEvents: false,
|
||||||
|
effectivenessStats: false,
|
||||||
verticalEvents: false,
|
verticalEvents: false,
|
||||||
fourRowEvents: false
|
fourRowEvents: false
|
||||||
},
|
},
|
||||||
@@ -380,6 +424,7 @@ const communityDataSlice = createSlice({
|
|||||||
error: {
|
error: {
|
||||||
popularKeywords: null,
|
popularKeywords: null,
|
||||||
hotEvents: null,
|
hotEvents: null,
|
||||||
|
effectivenessStats: null,
|
||||||
verticalEvents: null,
|
verticalEvents: null,
|
||||||
fourRowEvents: null
|
fourRowEvents: null
|
||||||
}
|
}
|
||||||
@@ -471,6 +516,26 @@ const communityDataSlice = createSlice({
|
|||||||
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
|
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
|
||||||
createDataReducers(builder, fetchHotEvents, 'hotEvents');
|
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 需要特殊处理(缓存 + 追加模式)
|
// dynamicNews 需要特殊处理(缓存 + 追加模式)
|
||||||
// 根据 mode 更新不同的 state(verticalEvents 或 fourRowEvents)
|
// 根据 mode 更新不同的 state(verticalEvents 或 fourRowEvents)
|
||||||
builder
|
builder
|
||||||
@@ -651,13 +716,18 @@ const communityDataSlice = createSlice({
|
|||||||
|
|
||||||
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions;
|
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions;
|
||||||
|
|
||||||
// 基础选择器(Selectors)
|
// ==================== 基础选择器(Selectors)====================
|
||||||
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
|
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
|
||||||
export const selectHotEvents = (state) => state.communityData.hotEvents;
|
export const selectHotEvents = (state) => state.communityData.hotEvents;
|
||||||
export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus;
|
export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus;
|
||||||
export const selectLoading = (state) => state.communityData.loading;
|
export const selectLoading = (state) => state.communityData.loading;
|
||||||
export const selectError = (state) => state.communityData.error;
|
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 selectVerticalEventsByPage = (state) => state.communityData.verticalEventsByPage;
|
||||||
export const selectVerticalPagination = (state) => state.communityData.verticalPagination;
|
export const selectVerticalPagination = (state) => state.communityData.verticalPagination;
|
||||||
@@ -705,4 +775,58 @@ export const selectFourRowEventsWithLoading = (state) => ({
|
|||||||
cachedCount: (state.communityData.fourRowEvents || []).length // 已缓存有效数量
|
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;
|
export default communityDataSlice.reducer;
|
||||||
|
|||||||
@@ -668,7 +668,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
|||||||
{...rest}
|
{...rest}
|
||||||
bg={cardBg}
|
bg={cardBg}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
mb={4}
|
mb={0}
|
||||||
position="relative"
|
position="relative"
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
animation="fadeInUp 0.8s ease-out 0.2s both"
|
animation="fadeInUp 0.8s ease-out 0.2s both"
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ const CompactEventCard = ({
|
|||||||
borderColor,
|
borderColor,
|
||||||
}) => {
|
}) => {
|
||||||
const importance = getImportanceConfig(event.importance);
|
const importance = getImportanceConfig(event.importance);
|
||||||
|
// 所有 useColorModeValue 必须在组件顶层调用
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
|
const cardBgAlt = useColorModeValue('gray.50', 'gray.750');
|
||||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ const CompactEventCard = ({
|
|||||||
{/* 右侧内容卡片 */}
|
{/* 右侧内容卡片 */}
|
||||||
<Card
|
<Card
|
||||||
flex="1"
|
flex="1"
|
||||||
bg={index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750')}
|
bg={index % 2 === 0 ? cardBg : cardBgAlt}
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
|
|||||||
@@ -44,8 +44,51 @@ const DynamicNewsEventCard = React.memo(({
|
|||||||
onToggleFollow,
|
onToggleFollow,
|
||||||
borderColor,
|
borderColor,
|
||||||
}) => {
|
}) => {
|
||||||
|
// ========== 所有 useColorModeValue 必须在组件顶层调用 ==========
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
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);
|
const importance = getImportanceConfig(event.importance);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,46 +131,42 @@ const DynamicNewsEventCard = React.memo(({
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取时间标签样式(根据交易时段)
|
* 获取时间标签样式(根据交易时段)- 使用预计算的颜色值
|
||||||
* @param {string} period - 交易时段
|
* @param {string} period - 交易时段
|
||||||
* @returns {Object} Chakra UI 样式对象
|
* @returns {Object} Chakra UI 样式对象
|
||||||
*/
|
*/
|
||||||
const getTimeLabelStyle = (period) => {
|
const getTimeLabelStyle = (period) => {
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case 'pre-market':
|
case 'pre-market':
|
||||||
// 盘前:粉红色系(浅红)
|
|
||||||
return {
|
return {
|
||||||
bg: useColorModeValue('pink.50', 'pink.900'),
|
bg: timeLabelColors.preMarket.bg,
|
||||||
borderColor: useColorModeValue('pink.300', 'pink.500'),
|
borderColor: timeLabelColors.preMarket.border,
|
||||||
textColor: useColorModeValue('pink.600', 'pink.300'),
|
textColor: timeLabelColors.preMarket.text,
|
||||||
};
|
};
|
||||||
case 'morning-trading':
|
case 'morning-trading':
|
||||||
case 'afternoon-trading':
|
case 'afternoon-trading':
|
||||||
// 盘中:红色系(强烈,表示交易活跃)
|
|
||||||
return {
|
return {
|
||||||
bg: useColorModeValue('red.50', 'red.900'),
|
bg: timeLabelColors.trading.bg,
|
||||||
borderColor: useColorModeValue('red.400', 'red.500'),
|
borderColor: timeLabelColors.trading.border,
|
||||||
textColor: useColorModeValue('red.700', 'red.300'),
|
textColor: timeLabelColors.trading.text,
|
||||||
};
|
};
|
||||||
case 'lunch-break':
|
case 'lunch-break':
|
||||||
// 午休:灰色系(中性)
|
|
||||||
return {
|
return {
|
||||||
bg: useColorModeValue('gray.100', 'gray.800'),
|
bg: timeLabelColors.lunchBreak.bg,
|
||||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
borderColor: timeLabelColors.lunchBreak.border,
|
||||||
textColor: useColorModeValue('gray.600', 'gray.400'),
|
textColor: timeLabelColors.lunchBreak.text,
|
||||||
};
|
};
|
||||||
case 'after-market':
|
case 'after-market':
|
||||||
// 盘后:橙色系(暖色但区别于盘中红色)
|
|
||||||
return {
|
return {
|
||||||
bg: useColorModeValue('orange.50', 'orange.900'),
|
bg: timeLabelColors.afterMarket.bg,
|
||||||
borderColor: useColorModeValue('orange.400', 'orange.500'),
|
borderColor: timeLabelColors.afterMarket.border,
|
||||||
textColor: useColorModeValue('orange.600', 'orange.300'),
|
textColor: timeLabelColors.afterMarket.text,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
bg: useColorModeValue('gray.100', 'gray.800'),
|
bg: timeLabelColors.lunchBreak.bg,
|
||||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
borderColor: timeLabelColors.lunchBreak.border,
|
||||||
textColor: useColorModeValue('gray.600', 'gray.400'),
|
textColor: timeLabelColors.lunchBreak.text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -154,7 +193,7 @@ const DynamicNewsEventCard = React.memo(({
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据平均涨幅计算背景色(分级策略)- 使用毛玻璃效果
|
* 根据平均涨幅计算背景色(分级策略)- 使用预计算的颜色值
|
||||||
* @param {number} avgChange - 平均涨跌幅
|
* @param {number} avgChange - 平均涨跌幅
|
||||||
* @returns {string} Chakra UI 颜色值
|
* @returns {string} Chakra UI 颜色值
|
||||||
*/
|
*/
|
||||||
@@ -163,28 +202,28 @@ const DynamicNewsEventCard = React.memo(({
|
|||||||
|
|
||||||
// 如果没有涨跌幅数据,使用半透明背景
|
// 如果没有涨跌幅数据,使用半透明背景
|
||||||
if (avgChange == null || isNaN(numChange)) {
|
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);
|
const absChange = Math.abs(numChange);
|
||||||
if (numChange > 0) {
|
if (numChange > 0) {
|
||||||
// 涨:红色系半透明
|
// 涨:红色系半透明
|
||||||
if (absChange >= 9) return useColorModeValue('rgba(254, 202, 202, 0.9)', 'rgba(127, 29, 29, 0.9)');
|
if (absChange >= 9) return changeBgColors.up9;
|
||||||
if (absChange >= 7) return useColorModeValue('rgba(254, 202, 202, 0.8)', 'rgba(153, 27, 27, 0.8)');
|
if (absChange >= 7) return changeBgColors.up7;
|
||||||
if (absChange >= 5) return useColorModeValue('rgba(254, 226, 226, 0.8)', 'rgba(185, 28, 28, 0.8)');
|
if (absChange >= 5) return changeBgColors.up5;
|
||||||
if (absChange >= 3) return useColorModeValue('rgba(254, 226, 226, 0.7)', 'rgba(220, 38, 38, 0.7)');
|
if (absChange >= 3) return changeBgColors.up3;
|
||||||
return useColorModeValue('rgba(254, 242, 242, 0.7)', 'rgba(239, 68, 68, 0.7)');
|
return changeBgColors.up0;
|
||||||
} else if (numChange < 0) {
|
} else if (numChange < 0) {
|
||||||
// 跌:绿色系半透明
|
// 跌:绿色系半透明
|
||||||
if (absChange >= 9) return useColorModeValue('rgba(187, 247, 208, 0.9)', 'rgba(20, 83, 45, 0.9)');
|
if (absChange >= 9) return changeBgColors.down9;
|
||||||
if (absChange >= 7) return useColorModeValue('rgba(187, 247, 208, 0.8)', 'rgba(22, 101, 52, 0.8)');
|
if (absChange >= 7) return changeBgColors.down7;
|
||||||
if (absChange >= 5) return useColorModeValue('rgba(209, 250, 229, 0.8)', 'rgba(21, 128, 61, 0.8)');
|
if (absChange >= 5) return changeBgColors.down5;
|
||||||
if (absChange >= 3) return useColorModeValue('rgba(209, 250, 229, 0.7)', 'rgba(22, 163, 74, 0.7)');
|
if (absChange >= 3) return changeBgColors.down3;
|
||||||
return useColorModeValue('rgba(240, 253, 244, 0.7)', 'rgba(34, 197, 94, 0.7)');
|
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
|
<Card
|
||||||
position="relative"
|
position="relative"
|
||||||
bg={isSelected
|
bg={isSelected ? selectedBg : getChangeBasedBgColor(event.related_avg_chg)}
|
||||||
? useColorModeValue('blue.50', 'blue.900')
|
|
||||||
: getChangeBasedBgColor(event.related_avg_chg)
|
|
||||||
}
|
|
||||||
backdropFilter={GLASS_BLUR.sm} // 毛玻璃效果
|
backdropFilter={GLASS_BLUR.sm} // 毛玻璃效果
|
||||||
borderWidth={isSelected ? "2px" : "1px"}
|
borderWidth={isSelected ? "2px" : "1px"}
|
||||||
borderColor={isSelected
|
borderColor={isSelected ? selectedBorderColor : borderColor}
|
||||||
? useColorModeValue('blue.500', 'blue.400')
|
|
||||||
: borderColor
|
|
||||||
}
|
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
boxShadow={isSelected ? "xl" : "md"}
|
boxShadow={isSelected ? "xl" : "md"}
|
||||||
overflow="visible"
|
overflow="visible"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* EventDailyStats - 事件 TOP 排行面板
|
* EventDailyStats - 事件 TOP 排行面板
|
||||||
* 展示当日事件的表现排行
|
* 展示当日事件的表现排行
|
||||||
|
*
|
||||||
|
* 【优化】使用 Redux 共享 effectivenessStats 数据
|
||||||
|
* 避免与 MarketOverviewBanner 重复请求
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback, useMemo, memo } from "react";
|
import React, { useEffect, useMemo, memo, useState, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
@@ -12,9 +15,14 @@ import {
|
|||||||
Center,
|
Center,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Badge,
|
Badge,
|
||||||
|
Input,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { motion, useAnimationControls } from "framer-motion";
|
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);
|
const MotionBox = motion.create(Box);
|
||||||
|
|
||||||
@@ -104,63 +112,52 @@ const ITEM_HEIGHT = 32;
|
|||||||
const VISIBLE_COUNT = 8;
|
const VISIBLE_COUNT = 8;
|
||||||
|
|
||||||
const EventDailyStats = () => {
|
const EventDailyStats = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const dispatch = useDispatch();
|
||||||
const [, setRefreshing] = useState(false);
|
const { loading, error, topPerformers, date: selectedDate } = useSelector(selectEffectivenessStatsWithLoading);
|
||||||
const [stats, setStats] = useState(null);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const controls = useAnimationControls();
|
const controls = useAnimationControls();
|
||||||
|
const dateInputRef = useRef(null);
|
||||||
|
|
||||||
const fetchStats = useCallback(async (isRefresh = false) => {
|
// 今天的日期(用于限制选择范围和判断是否自动刷新)
|
||||||
if (isRefresh) {
|
const today = new Date().toISOString().split("T")[0];
|
||||||
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) {
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
dispatch(fetchEffectivenessStats({ date: today, forceRefresh: false }));
|
||||||
}, [fetchStats]);
|
}, [dispatch, today]);
|
||||||
|
|
||||||
// 自动刷新(每60秒刷新一次)
|
// 自动刷新:仅当选择今天时启用(每60秒)
|
||||||
useEffect(() => {
|
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);
|
return () => clearInterval(interval);
|
||||||
}, [fetchStats]);
|
}, [dispatch, effectiveDate, today]);
|
||||||
|
|
||||||
|
// 日期变化处理
|
||||||
|
const handleDateChange = (e) => {
|
||||||
|
const newDate = e.target.value;
|
||||||
|
if (newDate) {
|
||||||
|
dispatch(fetchEffectivenessStats({ date: newDate, forceRefresh: true }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 获取显示列表(取前10个,复制一份用于无缝循环)
|
// 获取显示列表(取前10个,复制一份用于无缝循环)
|
||||||
const displayList = useMemo(() => {
|
const displayList = useMemo(() => {
|
||||||
const topPerformers = stats?.topPerformers || [];
|
const list = (topPerformers || []).slice(0, 10);
|
||||||
const list = topPerformers.slice(0, 10);
|
// 数据不足8个时不需要滚动
|
||||||
// 数据不足5个时不需要滚动
|
|
||||||
if (list.length <= VISIBLE_COUNT) return list;
|
if (list.length <= VISIBLE_COUNT) return list;
|
||||||
// 复制一份用于无缝循环
|
// 复制一份用于无缝循环
|
||||||
return [...list, ...list];
|
return [...list, ...list];
|
||||||
}, [stats]);
|
}, [topPerformers]);
|
||||||
|
|
||||||
const needScroll = displayList.length > VISIBLE_COUNT;
|
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;
|
const totalScrollHeight = originalCount * ITEM_HEIGHT;
|
||||||
|
|
||||||
// 滚动动画
|
// 滚动动画
|
||||||
@@ -185,7 +182,7 @@ const EventDailyStats = () => {
|
|||||||
startAnimation();
|
startAnimation();
|
||||||
}, [needScroll, isPaused, controls, totalScrollHeight, originalCount]);
|
}, [needScroll, isPaused, controls, totalScrollHeight, originalCount]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading && !topPerformers?.length) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
|
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
|
||||||
@@ -202,7 +199,7 @@ const EventDailyStats = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasData = stats && displayList.length > 0;
|
const hasData = displayList.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -253,6 +250,32 @@ const EventDailyStats = () => {
|
|||||||
<Text fontSize="sm" fontWeight="bold" color="white">
|
<Text fontSize="sm" fontWeight="bold" color="white">
|
||||||
事件 TOP 排行
|
事件 TOP 排行
|
||||||
</Text>
|
</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>
|
</HStack>
|
||||||
|
|
||||||
{/* 内容区域 - 固定高度显示8个,向上滚动轮播 */}
|
{/* 内容区域 - 固定高度显示8个,向上滚动轮播 */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// HeroPanel - 综合日历组件
|
// HeroPanel - 综合日历组件
|
||||||
import React, { useState, useEffect, useCallback, Suspense, lazy, memo } from "react";
|
import React, { useState, useEffect, useCallback, Suspense, lazy, memo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
HStack,
|
HStack,
|
||||||
@@ -43,12 +43,25 @@ const CombinedCalendar = memo(({ DetailModal }) => {
|
|||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
// 加载日历综合数据(一次 API 调用获取所有数据)
|
// 月份数据缓存(避免切换月份后再切回时重复请求)
|
||||||
|
const monthCacheRef = useRef({});
|
||||||
|
// 涨停详情缓存 ref(用于 handleDateClick 避免依赖 ztDailyDetails 状态)
|
||||||
|
const ztDailyDetailsRef = useRef({});
|
||||||
|
|
||||||
|
// 加载日历综合数据(带缓存)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCalendarCombinedData = async () => {
|
const loadCalendarCombinedData = async () => {
|
||||||
|
const year = currentMonth.getFullYear();
|
||||||
|
const month = currentMonth.getMonth() + 1;
|
||||||
|
const cacheKey = `${year}-${month}`;
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
if (monthCacheRef.current[cacheKey]) {
|
||||||
|
setCalendarData(monthCacheRef.current[cacheKey]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const year = currentMonth.getFullYear();
|
|
||||||
const month = currentMonth.getMonth() + 1;
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getApiBase()}/api/v1/calendar/combined-data?year=${year}&month=${month}`
|
`${getApiBase()}/api/v1/calendar/combined-data?year=${year}&month=${month}`
|
||||||
);
|
);
|
||||||
@@ -63,10 +76,8 @@ const CombinedCalendar = memo(({ DetailModal }) => {
|
|||||||
eventCount: item.event_count || 0,
|
eventCount: item.event_count || 0,
|
||||||
indexChange: item.index_change,
|
indexChange: item.index_change,
|
||||||
}));
|
}));
|
||||||
console.log(
|
// 存入缓存
|
||||||
"[HeroPanel] 加载日历综合数据成功,数据条数:",
|
monthCacheRef.current[cacheKey] = formattedData;
|
||||||
formattedData.length
|
|
||||||
);
|
|
||||||
setCalendarData(formattedData);
|
setCalendarData(formattedData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,56 +88,58 @@ const CombinedCalendar = memo(({ DetailModal }) => {
|
|||||||
loadCalendarCombinedData();
|
loadCalendarCombinedData();
|
||||||
}, [currentMonth]);
|
}, [currentMonth]);
|
||||||
|
|
||||||
// 处理日期点击 - 打开弹窗
|
// 处理日期点击 - 打开弹窗(使用 ref 避免依赖 ztDailyDetails 状态)
|
||||||
const handleDateClick = useCallback(
|
const handleDateClick = useCallback(async (date) => {
|
||||||
async (date) => {
|
setSelectedDate(date);
|
||||||
setSelectedDate(date);
|
setModalOpen(true);
|
||||||
setModalOpen(true);
|
setDetailLoading(true);
|
||||||
setDetailLoading(true);
|
|
||||||
|
|
||||||
const ztDateStr = formatDateStr(date);
|
const ztDateStr = formatDateStr(date);
|
||||||
const eventDateStr = dayjs(date).format("YYYY-MM-DD");
|
const eventDateStr = dayjs(date).format("YYYY-MM-DD");
|
||||||
|
|
||||||
// 加载涨停详情
|
// 加载涨停详情(使用 ref 访问缓存)
|
||||||
const detail = ztDailyDetails[ztDateStr];
|
const detail = ztDailyDetailsRef.current[ztDateStr];
|
||||||
if (detail?.fullData) {
|
if (detail?.fullData) {
|
||||||
setSelectedZtDetail(detail.fullData);
|
setSelectedZtDetail(detail.fullData);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/data/zt/daily/${ztDateStr}.json`);
|
const response = await fetch(`/data/zt/daily/${ztDateStr}.json`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setSelectedZtDetail(data);
|
setSelectedZtDetail(data);
|
||||||
setZtDailyDetails((prev) => ({
|
// 同时更新 ref 和 state
|
||||||
...prev,
|
ztDailyDetailsRef.current[ztDateStr] = {
|
||||||
[ztDateStr]: { ...prev[ztDateStr], fullData: data },
|
...ztDailyDetailsRef.current[ztDateStr],
|
||||||
}));
|
fullData: data,
|
||||||
} else {
|
};
|
||||||
setSelectedZtDetail(null);
|
setZtDailyDetails((prev) => ({
|
||||||
}
|
...prev,
|
||||||
} catch {
|
[ztDateStr]: { ...prev[ztDateStr], fullData: data },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
setSelectedZtDetail(null);
|
setSelectedZtDetail(null);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 加载事件详情
|
|
||||||
try {
|
|
||||||
const response = await eventService.calendar.getEventsForDate(
|
|
||||||
eventDateStr
|
|
||||||
);
|
|
||||||
if (response.success) {
|
|
||||||
setSelectedEvents(response.data || []);
|
|
||||||
} else {
|
|
||||||
setSelectedEvents([]);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
|
setSelectedZtDetail(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载事件详情
|
||||||
|
try {
|
||||||
|
const response = await eventService.calendar.getEventsForDate(
|
||||||
|
eventDateStr
|
||||||
|
);
|
||||||
|
if (response.success) {
|
||||||
|
setSelectedEvents(response.data || []);
|
||||||
|
} else {
|
||||||
setSelectedEvents([]);
|
setSelectedEvents([]);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setSelectedEvents([]);
|
||||||
|
}
|
||||||
|
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
},
|
}, []);
|
||||||
[ztDailyDetails]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 月份变化回调
|
// 月份变化回调
|
||||||
const handleMonthChange = useCallback((year, month) => {
|
const handleMonthChange = useCallback((year, month) => {
|
||||||
|
|||||||
@@ -268,14 +268,23 @@ const DetailModal = ({
|
|||||||
[dispatch, isStockInWatchlist]
|
[dispatch, isStockInWatchlist]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 加载股票行情(并行加载优化)
|
// 加载股票行情(并行加载 + 缓存去重)
|
||||||
const loadStockQuotes = useCallback(
|
const loadStockQuotes = useCallback(
|
||||||
async (stocks) => {
|
async (stocks) => {
|
||||||
if (!stocks || stocks.length === 0) return;
|
if (!stocks || stocks.length === 0) return;
|
||||||
|
|
||||||
|
// 过滤已缓存的股票,只请求未缓存的
|
||||||
|
const uncachedStocks = stocks.filter(
|
||||||
|
(stock) => !stockQuotes[stock.code]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果全部已缓存,无需请求
|
||||||
|
if (uncachedStocks.length === 0) return;
|
||||||
|
|
||||||
setStockQuotesLoading(true);
|
setStockQuotesLoading(true);
|
||||||
|
|
||||||
// 并行发起所有请求
|
// 并行发起未缓存股票的请求
|
||||||
const promises = stocks.map(async (stock) => {
|
const promises = uncachedStocks.map(async (stock) => {
|
||||||
const code = getSixDigitCode(stock.code);
|
const code = getSixDigitCode(stock.code);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@@ -304,18 +313,18 @@ const DetailModal = ({
|
|||||||
// 等待所有请求完成
|
// 等待所有请求完成
|
||||||
const results = await Promise.all(promises);
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
// 构建 quotes 对象
|
// 合并新数据到现有缓存
|
||||||
const quotes = {};
|
const newQuotes = { ...stockQuotes };
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
quotes[result.stockCode] = result.quote;
|
newQuotes[result.stockCode] = result.quote;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setStockQuotes(quotes);
|
setStockQuotes(newQuotes);
|
||||||
setStockQuotesLoading(false);
|
setStockQuotesLoading(false);
|
||||||
},
|
},
|
||||||
[setStockQuotes, setStockQuotesLoading]
|
[stockQuotes, setStockQuotes, setStockQuotesLoading]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 显示相关股票
|
// 显示相关股票
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* MarketOverviewBanner - 市场与事件概览通栏组件
|
* MarketOverviewBanner - 市场与事件概览通栏组件
|
||||||
* 顶部通栏展示市场涨跌分布和事件统计数据
|
* 顶部通栏展示市场涨跌分布和事件统计数据
|
||||||
|
*
|
||||||
|
* 【优化】使用 Redux 共享 effectivenessStats 数据
|
||||||
|
* 避免与 EventDailyStats 重复请求
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
@@ -20,7 +23,11 @@ import {
|
|||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import {
|
||||||
|
fetchEffectivenessStats,
|
||||||
|
selectEffectivenessStatsWithLoading,
|
||||||
|
} from "@store/slices/communityDataSlice";
|
||||||
|
|
||||||
// 模块化导入
|
// 模块化导入
|
||||||
import {
|
import {
|
||||||
@@ -37,56 +44,49 @@ import {
|
|||||||
import StockTop10Modal from "./MarketOverviewBanner/StockTop10Modal";
|
import StockTop10Modal from "./MarketOverviewBanner/StockTop10Modal";
|
||||||
|
|
||||||
const MarketOverviewBanner = () => {
|
const MarketOverviewBanner = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const dispatch = useDispatch();
|
||||||
const [stats, setStats] = useState(null);
|
const { loading, stats, topStocks, summary, marketStats, date: statsDate } =
|
||||||
|
useSelector(selectEffectivenessStatsWithLoading);
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState(
|
const [selectedDate, setSelectedDate] = useState(
|
||||||
new Date().toISOString().split("T")[0]
|
new Date().toISOString().split("T")[0]
|
||||||
);
|
);
|
||||||
const [stockModalVisible, setStockModalVisible] = useState(false);
|
const [stockModalVisible, setStockModalVisible] = useState(false);
|
||||||
const dateInputRef = useRef(null);
|
const dateInputRef = useRef(null);
|
||||||
|
|
||||||
const fetchStats = useCallback(async (dateStr = "", showLoading = false) => {
|
// 首次加载标记
|
||||||
if (showLoading) {
|
const isInitialMount = useRef(true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 首次加载显示 loading
|
// 首次加载:dispatch Redux action
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStats(selectedDate, true);
|
dispatch(fetchEffectivenessStats({ date: selectedDate, forceRefresh: false }));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 日期变化时静默刷新
|
// 日期变化时刷新(带防抖,跳过首次加载)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDate) {
|
if (isInitialMount.current) {
|
||||||
fetchStats(selectedDate, false);
|
isInitialMount.current = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [fetchStats, selectedDate]);
|
if (selectedDate) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
dispatch(fetchEffectivenessStats({ date: selectedDate, forceRefresh: true }));
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [dispatch, selectedDate]);
|
||||||
|
|
||||||
// 自动刷新(每60秒,仅当选择今天时)
|
// 自动刷新(每60秒,仅当选择今天时)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDate) {
|
const today = new Date().toISOString().split("T")[0];
|
||||||
const interval = setInterval(() => fetchStats(""), 60 * 1000);
|
if (selectedDate === today) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
dispatch(fetchEffectivenessStats({ date: selectedDate, forceRefresh: true }));
|
||||||
|
}, 60 * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [selectedDate, fetchStats]);
|
}, [selectedDate, dispatch]);
|
||||||
|
|
||||||
const handleDateChange = (e) => {
|
const handleDateChange = (e) => {
|
||||||
setSelectedDate(e.target.value);
|
setSelectedDate(e.target.value);
|
||||||
@@ -96,7 +96,7 @@ const MarketOverviewBanner = () => {
|
|||||||
dateInputRef.current?.showPicker?.();
|
dateInputRef.current?.showPicker?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading && !stats) {
|
||||||
return (
|
return (
|
||||||
<Box h="100px" display="flex" alignItems="center" justifyContent="center">
|
<Box h="100px" display="flex" alignItems="center" justifyContent="center">
|
||||||
<Spinner size="sm" color="yellow.400" />
|
<Spinner size="sm" color="yellow.400" />
|
||||||
@@ -106,7 +106,6 @@ const MarketOverviewBanner = () => {
|
|||||||
|
|
||||||
if (!stats) return null;
|
if (!stats) return null;
|
||||||
|
|
||||||
const { summary, marketStats, topStocks = [] } = stats;
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -141,7 +140,7 @@ const MarketOverviewBanner = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
{/* 实时标签 */}
|
{/* 实时标签 */}
|
||||||
{!selectedDate && (
|
{selectedDate === today && (
|
||||||
<HStack
|
<HStack
|
||||||
spacing={1}
|
spacing={1}
|
||||||
px={2}
|
px={2}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Y轴:板块热度(涨停家数)
|
* Y轴:板块热度(涨停家数)
|
||||||
* 支持时间滑动条查看历史数据
|
* 支持时间滑动条查看历史数据
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
@@ -38,6 +38,9 @@ import {
|
|||||||
/**
|
/**
|
||||||
* ThemeCometChart 主组件
|
* ThemeCometChart 主组件
|
||||||
*/
|
*/
|
||||||
|
// 缓存有效期(5 分钟)
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000;
|
||||||
|
|
||||||
const ThemeCometChart = ({ onThemeSelect }) => {
|
const ThemeCometChart = ({ onThemeSelect }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [allDatesData, setAllDatesData] = useState({});
|
const [allDatesData, setAllDatesData] = useState({});
|
||||||
@@ -48,8 +51,24 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// 加载所有日期的数据
|
// 数据缓存(避免 tab 切换时重复请求)
|
||||||
|
const dataCacheRef = useRef({ data: null, dates: null, timestamp: null });
|
||||||
|
|
||||||
|
// 加载所有日期的数据(带缓存)
|
||||||
const loadAllData = useCallback(async () => {
|
const loadAllData = useCallback(async () => {
|
||||||
|
// 检查缓存是否有效(5分钟内)
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
dataCacheRef.current.timestamp &&
|
||||||
|
now - dataCacheRef.current.timestamp < CACHE_DURATION &&
|
||||||
|
dataCacheRef.current.data
|
||||||
|
) {
|
||||||
|
setAllDatesData(dataCacheRef.current.data);
|
||||||
|
setAvailableDates(dataCacheRef.current.dates);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const apiBase = getApiBase();
|
const apiBase = getApiBase();
|
||||||
@@ -109,6 +128,12 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 存入缓存
|
||||||
|
dataCacheRef.current = {
|
||||||
|
data: dataCache,
|
||||||
|
dates: dates,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
setAllDatesData(dataCache);
|
setAllDatesData(dataCache);
|
||||||
setSliderIndex(0);
|
setSliderIndex(0);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -149,9 +149,9 @@ const Community = () => {
|
|||||||
}, [location.pathname, registerEventUpdateCallback]); // 依赖路由变化重新注册
|
}, [location.pathname, registerEventUpdateCallback]); // 依赖路由变化重新注册
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg={bgColor}>
|
<Box bg={bgColor} flex="1">
|
||||||
{/* 主内容区域 - padding 由 MainLayout 统一设置 */}
|
{/* 主内容区域 - padding 由 MainLayout 统一设置 */}
|
||||||
<Box ref={containerRef} pt={0} pb={{ base: 4, md: 8 }}>
|
<Box ref={containerRef} pt={0} pb={0}>
|
||||||
{/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
|
{/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">
|
<Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { FeaturedFeatureCard } from './components/FeaturedFeatureCard';
|
|||||||
import { FeatureCard } from './components/FeatureCard';
|
import { FeatureCard } from './components/FeatureCard';
|
||||||
import MiniProgramLauncher from '@/components/MiniProgramLauncher';
|
import MiniProgramLauncher from '@/components/MiniProgramLauncher';
|
||||||
import { isMobileDevice } from '@/components/MiniProgramLauncher/hooks/useWechatEnvironment';
|
import { isMobileDevice } from '@/components/MiniProgramLauncher/hooks/useWechatEnvironment';
|
||||||
import Center from '@views/Center';
|
// [暂时禁用] import Center from '@views/Center';
|
||||||
import '@/styles/home-animations.css';
|
import '@/styles/home-animations.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,64 +89,57 @@ const HomePage: React.FC = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已登录直接渲染个人中心
|
// [暂时禁用] 已登录直接渲染个人中心(未来可能恢复)
|
||||||
if (isAuthenticated && user) {
|
// if (isAuthenticated && user) {
|
||||||
return <Center />;
|
// return <Center />;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 未登录渲染首页内容
|
// 未登录渲染首页内容
|
||||||
return (
|
return (
|
||||||
<Box minH="100%">
|
<Box minH="100%">
|
||||||
{/* Hero Section - 深色科技风格,自适应容器高度 */}
|
{/* Hero Section - 深色科技风格,自适应容器高度 */}
|
||||||
<Box
|
|
||||||
position="relative"
|
|
||||||
minH="100%"
|
|
||||||
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
|
||||||
|
|
||||||
<Container maxW="7xl" position="relative" px={containerPx}>
|
<Container maxW="7xl" position="relative" px={containerPx}>
|
||||||
<VStack
|
<VStack
|
||||||
spacing={{ base: 5, md: 8, lg: 10 }}
|
spacing={{ base: 5, md: 8, lg: 10 }}
|
||||||
align="stretch"
|
align="stretch"
|
||||||
py={{ base: 8, md: 10, lg: 12 }}
|
py={{ base: 8, md: 10, lg: 12 }}
|
||||||
justify="center"
|
justify="center"
|
||||||
>
|
>
|
||||||
{/* 主标题区域 */}
|
{/* 主标题区域 */}
|
||||||
<HeroHeader
|
<HeroHeader
|
||||||
headingSize={headingSize}
|
headingSize={headingSize}
|
||||||
headingLetterSpacing={headingLetterSpacing}
|
headingLetterSpacing={headingLetterSpacing}
|
||||||
heroTextSize={heroTextSize}
|
heroTextSize={heroTextSize}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 核心功能面板 */}
|
{/* 核心功能面板 */}
|
||||||
<Box pb={{ base: 5, md: 8 }}>
|
<Box pb={{ base: 5, md: 8 }}>
|
||||||
<VStack spacing={{ base: 4, md: 5 }}>
|
<VStack spacing={{ base: 4, md: 5 }}>
|
||||||
{/* 特色功能卡片 - 新闻中心 */}
|
{/* 特色功能卡片 - 新闻中心 */}
|
||||||
<FeaturedFeatureCard
|
<FeaturedFeatureCard
|
||||||
feature={featuredFeature}
|
feature={featuredFeature}
|
||||||
onClick={handleFeatureClick}
|
onClick={handleFeatureClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 其他功能卡片 */}
|
{/* 其他功能卡片 */}
|
||||||
<SimpleGrid
|
<SimpleGrid
|
||||||
columns={{ base: 1, md: 2, lg: 3 }}
|
columns={{ base: 1, md: 2, lg: 3 }}
|
||||||
spacing={{ base: 2, md: 3, lg: 4 }}
|
spacing={{ base: 2, md: 3, lg: 4 }}
|
||||||
w="100%"
|
w="100%"
|
||||||
>
|
>
|
||||||
{regularFeatures.map((feature) => (
|
{regularFeatures.map((feature) => (
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
key={feature.id}
|
key={feature.id}
|
||||||
feature={feature}
|
feature={feature}
|
||||||
onClick={handleFeatureClick}
|
onClick={handleFeatureClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 移动端右上角固定按钮 - 小程序入口 */}
|
{/* 移动端右上角固定按钮 - 小程序入口 */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|||||||
Reference in New Issue
Block a user