Compare commits
18 Commits
76360102bb
...
be0c53b588
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be0c53b588 | ||
|
|
de1b31c70e | ||
|
|
d96ebd6b8c | ||
|
|
67127aa615 | ||
|
|
e7c495a8b1 | ||
|
|
e0cfa6fab2 | ||
|
|
c51d3811e5 | ||
|
|
8fe13c9fa4 | ||
|
|
e6c422887c | ||
|
|
7e110111c4 | ||
|
|
38d1b51af3 | ||
|
|
c7334191e5 | ||
|
|
7fdc9e26af | ||
|
|
7f01a391e0 | ||
|
|
58db08ca22 | ||
|
|
bf75f9b387 | ||
|
|
2a59e9edb2 | ||
|
|
87476226c3 |
@@ -22,6 +22,7 @@
|
||||
"@react-three/fiber": "^8.0.27",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@visx/visx": "^3.12.0",
|
||||
"antd": "^5.27.4",
|
||||
|
||||
@@ -13,6 +13,15 @@ import App from './App';
|
||||
|
||||
// 注册 Service Worker(用于支持浏览器通知)
|
||||
function registerServiceWorker() {
|
||||
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
console.log(
|
||||
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅在支持 Service Worker 的浏览器中注册
|
||||
if ('serviceWorker' in navigator) {
|
||||
// 在页面加载完成后注册
|
||||
|
||||
@@ -92,7 +92,7 @@ export const initPostHog = () => {
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
posthogInstance.debug(); // Enable debug mode in development
|
||||
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -143,7 +143,7 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
console.log('👤 User identified:', userId);
|
||||
// console.log('👤 User identified:', userId); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
console.log('📝 User properties updated');
|
||||
// console.log('📝 User properties updated'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
@@ -177,9 +177,9 @@ export const trackEvent = (eventName, properties = {}) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📍 Event tracked:', eventName, properties);
|
||||
}
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📍 Event tracked:', eventName, properties);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
@@ -201,9 +201,9 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
...properties,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📄 Page view tracked:', pagePath);
|
||||
}
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📄 Page view tracked:', pagePath);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
console.log('🔄 User session reset');
|
||||
// console.log('🔄 User session reset'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export const resetUser = () => {
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
console.log('🚫 User opted out of tracking');
|
||||
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export const optOut = () => {
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
console.log('✅ User opted in to tracking');
|
||||
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
|
||||
@@ -112,20 +112,20 @@ export function getCurrentUser() {
|
||||
const stored = localStorage.getItem('mock_current_user');
|
||||
if (stored) {
|
||||
const user = JSON.parse(stored);
|
||||
console.log('[Mock State] 获取当前登录用户:', {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
subscription_type: user.subscription_type,
|
||||
subscription_status: user.subscription_status,
|
||||
subscription_days_left: user.subscription_days_left
|
||||
});
|
||||
// console.log('[Mock State] 获取当前登录用户:', { // 已关闭:减少日志
|
||||
// id: user.id,
|
||||
// phone: user.phone,
|
||||
// nickname: user.nickname,
|
||||
// subscription_type: user.subscription_type,
|
||||
// subscription_status: user.subscription_status,
|
||||
// subscription_days_left: user.subscription_days_left
|
||||
// });
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Mock State] 解析用户数据失败:', error);
|
||||
}
|
||||
console.log('[Mock State] 未找到当前登录用户');
|
||||
// console.log('[Mock State] 未找到当前登录用户'); // 已关闭:减少日志
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export const accountHandlers = [
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Mock] 获取自选股列表');
|
||||
// console.log('[Mock] 获取自选股列表'); // 已关闭:减少日志
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -24,8 +24,6 @@ export const authHandlers = [
|
||||
const body = await request.json();
|
||||
const { credential, type, purpose } = body;
|
||||
|
||||
console.log('[Mock] 发送验证码:', { credential, type, purpose });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(credential, {
|
||||
@@ -33,7 +31,20 @@ export const authHandlers = [
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
console.log(`[Mock] 验证码已生成: ${credential} -> ${code}`);
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 验证码: ${code.padEnd(22)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
@@ -43,6 +54,86 @@ export const authHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 1.1 发送手机验证码(前端实际调用的接口)
|
||||
http.post('/api/auth/send-sms-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
const { phone } = body;
|
||||
|
||||
console.log('[Mock] 发送手机验证码请求:', { phone });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(phone, {
|
||||
code,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 📱 手机验证码: ${code.padEnd(19)}║\n` +
|
||||
`║ 📞 手机号: ${phone.padEnd(23)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 📱 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: `验证码已发送到 ${phone}(Mock: ${code})`,
|
||||
// 开发环境下返回验证码,方便测试
|
||||
dev_code: code
|
||||
});
|
||||
}),
|
||||
|
||||
// 1.2 发送邮箱验证码(前端实际调用的接口)
|
||||
http.post('/api/auth/send-email-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
const { email } = body;
|
||||
|
||||
console.log('[Mock] 发送邮箱验证码请求:', { email });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(email, {
|
||||
code,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 📧 邮箱验证码: ${code.padEnd(19)}║\n` +
|
||||
`║ 📮 邮箱: ${email.padEnd(27)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #2563eb; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 📧 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: `验证码已发送到 ${email}(Mock: ${code})`,
|
||||
// 开发环境下返回验证码,方便测试
|
||||
dev_code: code
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 验证码登录
|
||||
http.post('/api/auth/login-with-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
@@ -130,7 +130,7 @@ export const stockHandlers = [
|
||||
try {
|
||||
const stocks = generateStockList();
|
||||
|
||||
console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length });
|
||||
// console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length }); // 已关闭:减少日志
|
||||
|
||||
return HttpResponse.json(stocks);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js
|
||||
// 虚拟化网格组件(支持多列布局 + 纵向滚动 + 无限滚动)
|
||||
|
||||
import React, { useRef, useMemo, useEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Box, Grid, Spinner, Text, VStack, Center } from '@chakra-ui/react';
|
||||
import { useColorModeValue } from '@chakra-ui/react';
|
||||
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
|
||||
/**
|
||||
* 虚拟化网格组件(支持多列布局 + 无限滚动)
|
||||
* @param {Object} props
|
||||
* @param {Array} props.events - 事件列表(累积显示)
|
||||
* @param {number} props.columnsPerRow - 每行列数(默认 4,单列模式传 1)
|
||||
* @param {React.Component} props.CardComponent - 卡片组件(默认 DynamicNewsEventCard)
|
||||
* @param {Object} props.selectedEvent - 当前选中的事件
|
||||
* @param {Function} props.onEventSelect - 事件选择回调
|
||||
* @param {Object} props.eventFollowStatus - 事件关注状态
|
||||
* @param {Function} props.onToggleFollow - 关注切换回调
|
||||
* @param {Function} props.getTimelineBoxStyle - 时间轴样式获取函数
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
* @param {Function} props.loadNextPage - 加载下一页(无限滚动)
|
||||
* @param {boolean} props.hasMore - 是否还有更多数据
|
||||
* @param {boolean} props.loading - 加载状态
|
||||
*/
|
||||
const VirtualizedFourRowGrid = ({
|
||||
events,
|
||||
columnsPerRow = 4,
|
||||
CardComponent = DynamicNewsEventCard,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
eventFollowStatus,
|
||||
onToggleFollow,
|
||||
getTimelineBoxStyle,
|
||||
borderColor,
|
||||
loadNextPage,
|
||||
hasMore,
|
||||
loading,
|
||||
}) => {
|
||||
const parentRef = useRef(null);
|
||||
const isLoadingMore = useRef(false); // 防止重复加载
|
||||
|
||||
// 滚动条颜色(主题适配)
|
||||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||||
|
||||
// 将事件按 columnsPerRow 个一组分成行
|
||||
const rows = useMemo(() => {
|
||||
const r = [];
|
||||
for (let i = 0; i < events.length; i += columnsPerRow) {
|
||||
r.push(events.slice(i, i + columnsPerRow));
|
||||
}
|
||||
return r;
|
||||
}, [events, columnsPerRow]);
|
||||
|
||||
// 配置虚拟滚动器(纵向滚动 + 动态高度测量)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 250, // 提供初始估算值,库会自动测量实际高度
|
||||
overscan: 2, // 预加载2行(上下各1行)
|
||||
});
|
||||
|
||||
// 无限滚动逻辑 - 监听滚动事件,到达底部时加载下一页
|
||||
useEffect(() => {
|
||||
const scrollElement = parentRef.current;
|
||||
if (!scrollElement || !loadNextPage) return;
|
||||
|
||||
const handleScroll = async () => {
|
||||
// 防止重复触发
|
||||
if (isLoadingMore.current || !hasMore || loading) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||
|
||||
// 滚动到 80% 时开始加载下一页
|
||||
if (scrollPercentage > 0.8) {
|
||||
console.log('%c📜 [无限滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
||||
isLoadingMore.current = true;
|
||||
await loadNextPage();
|
||||
isLoadingMore.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll);
|
||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||
}, [loadNextPage, hasMore, loading]);
|
||||
|
||||
// 底部加载指示器
|
||||
const renderLoadingIndicator = () => {
|
||||
if (!hasMore) {
|
||||
return (
|
||||
<Center py={6}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
已加载全部内容
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={6}>
|
||||
<VStack spacing={2}>
|
||||
<Spinner size="md" color="blue.500" thickness="3px" />
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
加载中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={parentRef}
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
maxH="600px"
|
||||
w="100%"
|
||||
position="relative"
|
||||
css={{
|
||||
// 滚动条样式
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{/* 虚拟滚动容器 + 底部加载指示器 */}
|
||||
<Box position="relative" w="100%">
|
||||
{/* 虚拟滚动内容 */}
|
||||
<Box
|
||||
position="relative"
|
||||
w="100%"
|
||||
h={`${rowVirtualizer.getTotalSize()}px`}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowEvents = rows[virtualRow.index];
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
transform={`translateY(${virtualRow.start}px)`}
|
||||
>
|
||||
{/* 使用 Grid 横向排列卡片(列数由 columnsPerRow 决定) */}
|
||||
<Grid
|
||||
templateColumns={`repeat(${columnsPerRow}, 1fr)`}
|
||||
gap={columnsPerRow === 1 ? 3 : 4}
|
||||
w="100%"
|
||||
>
|
||||
{rowEvents.map((event, colIndex) => (
|
||||
<Box key={event.id}>
|
||||
<CardComponent
|
||||
event={event}
|
||||
index={virtualRow.index * columnsPerRow + colIndex}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={(clickedEvent) => {
|
||||
onEventSelect(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventSelect(event);
|
||||
}}
|
||||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||||
timelineStyle={getTimelineBoxStyle?.()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* 底部加载指示器 - 绝对定位在虚拟内容底部 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={`${rowVirtualizer.getTotalSize()}px`}
|
||||
left={0}
|
||||
right={0}
|
||||
w="100%"
|
||||
>
|
||||
{renderLoadingIndicator()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualizedFourRowGrid;
|
||||
@@ -5,6 +5,8 @@
|
||||
export const PAGINATION_CONFIG = {
|
||||
CAROUSEL_PAGE_SIZE: 5, // 单排模式每页数量
|
||||
GRID_PAGE_SIZE: 10, // 双排模式每页数量
|
||||
FOUR_ROW_PAGE_SIZE: 20, // 四排模式每页数量
|
||||
VERTICAL_PAGE_SIZE: 10, // 纵向模式每页数量
|
||||
INITIAL_PAGE: 1, // 初始页码
|
||||
PRELOAD_RANGE: 2, // 预加载范围(前后各N页)
|
||||
};
|
||||
@@ -13,6 +15,8 @@ export const PAGINATION_CONFIG = {
|
||||
export const DISPLAY_MODES = {
|
||||
CAROUSEL: 'carousel', // 单排轮播模式
|
||||
GRID: 'grid', // 双排网格模式
|
||||
FOUR_ROW: 'four-row', // 四排网格模式
|
||||
VERTICAL: 'vertical', // 纵向分栏模式
|
||||
};
|
||||
|
||||
export const DEFAULT_MODE = DISPLAY_MODES.CAROUSEL;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js
|
||||
// 无限滚动 Hook
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 无限滚动 Hook
|
||||
* 监听容器滚动事件,当滚动到底部附近时触发加载更多数据
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.onLoadMore - 加载更多回调函数(返回 Promise)
|
||||
* @param {boolean} options.hasMore - 是否还有更多数据
|
||||
* @param {boolean} options.isLoading - 是否正在加载
|
||||
* @param {number} options.threshold - 触发阈值(距离底部多少像素时触发,默认200px)
|
||||
* @returns {Object} { containerRef } - 容器引用
|
||||
*/
|
||||
export const useInfiniteScroll = ({
|
||||
onLoadMore,
|
||||
hasMore = true,
|
||||
isLoading = false,
|
||||
threshold = 200
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 滚动处理函数
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
// 检查条件:容器存在、未加载中、还有更多数据
|
||||
if (!container || isLoadingRef.current || !hasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
// 距离底部小于阈值时触发加载
|
||||
if (distanceToBottom < threshold) {
|
||||
console.log(
|
||||
'%c⬇️ [懒加载] 触发加载下一页',
|
||||
'color: #8B5CF6; font-weight: bold;',
|
||||
{
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToBottom,
|
||||
threshold
|
||||
}
|
||||
);
|
||||
|
||||
isLoadingRef.current = true;
|
||||
|
||||
// 调用加载函数并更新状态
|
||||
onLoadMore()
|
||||
.then(() => {
|
||||
console.log('%c✅ [懒加载] 加载完成', 'color: #10B981; font-weight: bold;');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('%c❌ [懒加载] 加载失败', 'color: #DC2626; font-weight: bold;', error);
|
||||
})
|
||||
.finally(() => {
|
||||
isLoadingRef.current = false;
|
||||
});
|
||||
}
|
||||
}, [onLoadMore, hasMore, threshold]);
|
||||
|
||||
// 绑定滚动事件
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 添加滚动监听
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
// 更新 loading 状态的 ref
|
||||
useEffect(() => {
|
||||
isLoadingRef.current = isLoading;
|
||||
}, [isLoading]);
|
||||
|
||||
return { containerRef };
|
||||
};
|
||||
@@ -22,25 +22,29 @@ import {
|
||||
* @returns {Object} 分页状态和方法
|
||||
*/
|
||||
export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, toast }) => {
|
||||
// 组件挂载状态跟踪 - 用于防止内存泄漏
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 本地状态
|
||||
const [currentPage, setCurrentPage] = useState(PAGINATION_CONFIG.INITIAL_PAGE);
|
||||
const [loadingPage, setLoadingPage] = useState(null);
|
||||
const [mode, setMode] = useState(DEFAULT_MODE);
|
||||
|
||||
// 组件卸载时更新挂载状态
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
// 累积显示的事件列表(用于四排模式的无限滚动)
|
||||
const [accumulatedEvents, setAccumulatedEvents] = useState([]);
|
||||
|
||||
// 根据模式决定每页显示数量
|
||||
const pageSize = mode === DISPLAY_MODES.CAROUSEL
|
||||
? PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE
|
||||
: PAGINATION_CONFIG.GRID_PAGE_SIZE;
|
||||
const pageSize = (() => {
|
||||
switch (mode) {
|
||||
case DISPLAY_MODES.CAROUSEL:
|
||||
return PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE;
|
||||
case DISPLAY_MODES.GRID:
|
||||
return PAGINATION_CONFIG.GRID_PAGE_SIZE;
|
||||
case DISPLAY_MODES.FOUR_ROW:
|
||||
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
|
||||
case DISPLAY_MODES.VERTICAL:
|
||||
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
|
||||
default:
|
||||
return PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE;
|
||||
}
|
||||
})();
|
||||
|
||||
// 计算总页数(基于服务端总数据量)
|
||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||
@@ -48,6 +52,9 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
||||
// 检查是否还有更多数据
|
||||
const hasMore = cachedCount < total;
|
||||
|
||||
// 判断是否使用累积模式(四排模式)
|
||||
const isAccumulateMode = mode === DISPLAY_MODES.FOUR_ROW;
|
||||
|
||||
// 从缓存中切片获取当前页数据(过滤 null 占位符)
|
||||
const currentPageEvents = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
@@ -55,6 +62,17 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
||||
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
|
||||
}, [allCachedEvents, currentPage, pageSize]);
|
||||
|
||||
// 当前显示的事件列表(累积模式 vs 分页模式)
|
||||
const displayEvents = useMemo(() => {
|
||||
if (isAccumulateMode) {
|
||||
// 四排模式:累积显示所有已加载的事件
|
||||
return accumulatedEvents;
|
||||
} else {
|
||||
// 其他模式:只显示当前页
|
||||
return currentPageEvents;
|
||||
}
|
||||
}, [isAccumulateMode, accumulatedEvents, currentPageEvents]);
|
||||
|
||||
/**
|
||||
* 子函数1: 检查目标页缓存状态
|
||||
* @param {number} targetPage - 目标页码
|
||||
@@ -65,7 +83,10 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
||||
const targetPageEndIndex = targetPageStartIndex + pageSize;
|
||||
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
|
||||
const validTargetData = targetPageData.filter(e => e !== null);
|
||||
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
|
||||
// 修复:确保 expectedCount 不为负数
|
||||
// - 当 total = 0 时,expectedCount = pageSize,强制发起请求
|
||||
// - 当 total - targetPageStartIndex < 0 时,expectedCount = 0
|
||||
const expectedCount = total === 0 ? pageSize : Math.max(0, Math.min(pageSize, total - targetPageStartIndex));
|
||||
const isTargetPageCached = validTargetData.length >= expectedCount;
|
||||
|
||||
logger.debug('DynamicNewsCard', '目标页缓存检查', {
|
||||
@@ -89,275 +110,177 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
||||
};
|
||||
}, [allCachedEvents, pageSize, total]);
|
||||
|
||||
// 已删除: calculatePreloadRange(不再需要预加载)
|
||||
|
||||
// 已删除: findMissingPages(不再需要查找缺失页面)
|
||||
|
||||
/**
|
||||
* 子函数2: 计算预加载范围
|
||||
* 加载单个页面数据
|
||||
* @param {number} targetPage - 目标页码
|
||||
* @param {number} fromPage - 来源页码
|
||||
* @returns {Array<number>} 预加载页码数组
|
||||
*/
|
||||
const calculatePreloadRange = useCallback((targetPage, fromPage) => {
|
||||
const isSequentialNavigation = Math.abs(targetPage - fromPage) === 1;
|
||||
|
||||
let preloadRange;
|
||||
if (isSequentialNavigation) {
|
||||
// 连续翻页:前后各N页(N = PRELOAD_RANGE)
|
||||
const start = Math.max(1, targetPage - PAGINATION_CONFIG.PRELOAD_RANGE);
|
||||
const end = Math.min(totalPages, targetPage + PAGINATION_CONFIG.PRELOAD_RANGE);
|
||||
preloadRange = Array.from(
|
||||
{ length: end - start + 1 },
|
||||
(_, i) => start + i
|
||||
);
|
||||
} else {
|
||||
// 跳转翻页:只加载当前页
|
||||
preloadRange = [targetPage];
|
||||
}
|
||||
|
||||
logger.debug('DynamicNewsCard', '计算预加载范围', {
|
||||
targetPage,
|
||||
fromPage,
|
||||
isSequentialNavigation,
|
||||
preloadRange
|
||||
});
|
||||
|
||||
return preloadRange;
|
||||
}, [totalPages]);
|
||||
|
||||
/**
|
||||
* 子函数3: 查找缺失页面
|
||||
* @param {Array<number>} preloadRange - 预加载范围
|
||||
* @returns {Array<number>} 缺失页码数组
|
||||
*/
|
||||
const findMissingPages = useCallback((preloadRange) => {
|
||||
const missingPages = preloadRange.filter(page => {
|
||||
const pageStartIndex = (page - 1) * pageSize;
|
||||
const pageEndIndex = pageStartIndex + pageSize;
|
||||
|
||||
// 如果该页超出数组范围,说明未缓存
|
||||
if (pageEndIndex > allCachedEvents.length) {
|
||||
logger.debug('DynamicNewsCard', `页面${page}超出数组范围`, {
|
||||
pageStartIndex,
|
||||
pageEndIndex,
|
||||
allCachedEventsLength: allCachedEvents.length
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查该页的数据是否包含 null 占位符或数据不足
|
||||
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
|
||||
const validData = pageData.filter(e => e !== null);
|
||||
const expectedCount = Math.min(pageSize, total - pageStartIndex);
|
||||
const hasNullOrIncomplete = validData.length < expectedCount;
|
||||
|
||||
logger.debug('DynamicNewsCard', `页面${page}数据检查`, {
|
||||
pageStartIndex,
|
||||
pageEndIndex,
|
||||
pageDataLength: pageData.length,
|
||||
validDataLength: validData.length,
|
||||
expectedCount,
|
||||
hasNullOrIncomplete
|
||||
});
|
||||
|
||||
return hasNullOrIncomplete;
|
||||
});
|
||||
|
||||
logger.debug('DynamicNewsCard', '缺失页面检测完成', {
|
||||
preloadRange,
|
||||
missingPages,
|
||||
missingPagesCount: missingPages.length
|
||||
});
|
||||
|
||||
return missingPages;
|
||||
}, [allCachedEvents, pageSize, total]);
|
||||
|
||||
/**
|
||||
* 子函数4: 加载页面数据
|
||||
* @param {Array<number>} missingPages - 缺失页码数组
|
||||
* @param {number} targetPage - 目标页码
|
||||
* @param {boolean} silentMode - 静默模式(后台预加载)
|
||||
* @returns {Promise<boolean>} 是否加载成功
|
||||
*/
|
||||
const loadPages = useCallback(async (missingPages, targetPage, silentMode = false) => {
|
||||
// 检查组件是否已卸载
|
||||
if (!isMountedRef.current) {
|
||||
logger.debug('DynamicNewsCard', '组件已卸载,取消加载');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!silentMode) {
|
||||
// 显示 loading 状态
|
||||
setLoadingPage(targetPage);
|
||||
}
|
||||
const loadPage = useCallback(async (targetPage) => {
|
||||
// 显示 loading 状态
|
||||
setLoadingPage(targetPage);
|
||||
|
||||
try {
|
||||
console.log(`%c🟢 [API请求] 开始加载第${targetPage}页数据`, 'color: #16A34A; font-weight: bold;');
|
||||
console.log(`%c 请求参数: page=${targetPage}, per_page=${pageSize}`, 'color: #16A34A;');
|
||||
|
||||
logger.debug('DynamicNewsCard', '开始加载页面数据', {
|
||||
missingPages,
|
||||
targetPage,
|
||||
silentMode,
|
||||
pageSize
|
||||
});
|
||||
|
||||
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
|
||||
for (const page of missingPages) {
|
||||
// 每次请求前检查组件是否已卸载
|
||||
if (!isMountedRef.current) {
|
||||
logger.debug('DynamicNewsCard', '组件已卸载,中止加载');
|
||||
return false;
|
||||
}
|
||||
await dispatch(fetchDynamicNews({
|
||||
page: targetPage,
|
||||
per_page: pageSize,
|
||||
pageSize: pageSize,
|
||||
clearCache: false
|
||||
})).unwrap();
|
||||
|
||||
logger.debug('DynamicNewsCard', `开始加载第 ${page} 页`);
|
||||
|
||||
await dispatch(fetchDynamicNews({
|
||||
page: page,
|
||||
per_page: pageSize, // 固定值(5或10),不使用动态计算
|
||||
pageSize: pageSize,
|
||||
clearCache: false
|
||||
})).unwrap();
|
||||
|
||||
logger.debug('DynamicNewsCard', `第 ${page} 页加载完成`);
|
||||
}
|
||||
|
||||
logger.debug('DynamicNewsCard', '所有页面加载完成', {
|
||||
missingPages,
|
||||
silentMode
|
||||
});
|
||||
console.log(`%c🟢 [API请求] 第${targetPage}页加载完成`, 'color: #16A34A; font-weight: bold;');
|
||||
logger.debug('DynamicNewsCard', `第 ${targetPage} 页加载完成`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('DynamicNewsCard', 'loadPages', error, {
|
||||
targetPage,
|
||||
silentMode,
|
||||
missingPages
|
||||
logger.error('DynamicNewsCard', 'loadPage', error, {
|
||||
targetPage
|
||||
});
|
||||
|
||||
// 只在组件仍挂载时显示错误提示
|
||||
if (!silentMode && isMountedRef.current) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: `无法加载第 ${targetPage} 页数据,请稍后重试`,
|
||||
status: 'error',
|
||||
duration: TOAST_CONFIG.DURATION_ERROR,
|
||||
isClosable: true,
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: `无法加载第 ${targetPage} 页数据,请稍后重试`,
|
||||
status: 'error',
|
||||
duration: TOAST_CONFIG.DURATION_ERROR,
|
||||
isClosable: true,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
// 只在组件仍挂载时清除加载状态
|
||||
if (!silentMode && isMountedRef.current) {
|
||||
setLoadingPage(null);
|
||||
}
|
||||
setLoadingPage(null);
|
||||
}
|
||||
}, [dispatch, pageSize, toast]);
|
||||
|
||||
// 翻页处理(智能预加载)- 使用子函数重构
|
||||
// 翻页处理(简化版 - 无预加载)
|
||||
const handlePageChange = useCallback(async (newPage) => {
|
||||
// 检查组件是否已卸载
|
||||
if (!isMountedRef.current) {
|
||||
logger.debug('DynamicNewsCard', '组件已卸载,取消翻页');
|
||||
return;
|
||||
}
|
||||
console.log(`%c🔵 [翻页逻辑] handlePageChange 开始`, 'color: #3B82F6; font-weight: bold;');
|
||||
console.log(`%c 当前页: ${currentPage}, 目标页: ${newPage}, 总页数: ${totalPages}`, 'color: #3B82F6;');
|
||||
console.log(`%c 每页大小: ${pageSize}, 缓存总数: ${allCachedEvents.length}, 服务端总数: ${total}`, 'color: #3B82F6;');
|
||||
|
||||
// 🔍 诊断日志 - 记录翻页开始状态
|
||||
logger.debug('DynamicNewsCard', '开始翻页', {
|
||||
currentPage,
|
||||
newPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
hasMore,
|
||||
total,
|
||||
allCachedEventsLength: allCachedEvents.length,
|
||||
cachedCount
|
||||
});
|
||||
|
||||
// 步骤1: 检查目标页缓存状态
|
||||
const { isTargetPageCached } = checkTargetPageCache(newPage);
|
||||
// 特殊处理:返回第一页 - 清空缓存重新加载
|
||||
if (newPage === 1) {
|
||||
logger.debug('DynamicNewsCard', '返回第一页,清空缓存重新加载');
|
||||
setCurrentPage(1);
|
||||
dispatch(fetchDynamicNews({
|
||||
page: 1,
|
||||
per_page: pageSize,
|
||||
pageSize: pageSize,
|
||||
clearCache: true // 清空缓存
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 步骤2: 计算预加载范围
|
||||
const preloadRange = calculatePreloadRange(newPage, currentPage);
|
||||
// 检查目标页缓存状态
|
||||
const { isTargetPageCached, targetPageInfo } = checkTargetPageCache(newPage);
|
||||
|
||||
// 步骤3: 查找缺失页面
|
||||
const missingPages = findMissingPages(preloadRange);
|
||||
console.log(`%c🟡 [缓存检查] 目标页${newPage}缓存状态`, 'color: #EAB308; font-weight: bold;');
|
||||
console.log(`%c 是否已缓存: ${isTargetPageCached ? '✅ 是' : '❌ 否'}`, `color: ${isTargetPageCached ? '#16A34A' : '#DC2626'};`);
|
||||
console.log(`%c 索引范围: ${targetPageInfo.startIndex}-${targetPageInfo.endIndex}`, 'color: #EAB308;');
|
||||
console.log(`%c 实际数量: ${targetPageInfo.validCount}, 期望数量: ${targetPageInfo.expectedCount}`, 'color: #EAB308;');
|
||||
|
||||
// 步骤4: 根据情况加载数据
|
||||
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
|
||||
// 场景A: 目标页已缓存,立即切换,后台静默预加载其他页
|
||||
logger.debug('DynamicNewsCard', '目标页已缓存,立即切换 + 后台预加载', {
|
||||
currentPage,
|
||||
newPage,
|
||||
缺失页面: missingPages
|
||||
});
|
||||
|
||||
// 只在组件仍挂载时更新状态
|
||||
if (isMountedRef.current) {
|
||||
setCurrentPage(newPage);
|
||||
}
|
||||
await loadPages(missingPages, newPage, true); // 静默模式
|
||||
} else if (missingPages.length > 0 && hasMore) {
|
||||
// 场景B: 目标页未缓存,显示 loading 并等待加载完成
|
||||
logger.debug('DynamicNewsCard', '目标页未缓存,显示 loading', {
|
||||
currentPage,
|
||||
newPage,
|
||||
缺失页面: missingPages
|
||||
});
|
||||
|
||||
const success = await loadPages(missingPages, newPage, false); // 非静默模式
|
||||
// 只在加载成功且组件仍挂载时更新状态
|
||||
if (success && isMountedRef.current) {
|
||||
setCurrentPage(newPage);
|
||||
}
|
||||
} else if (missingPages.length === 0) {
|
||||
// 场景C: 所有页面均已缓存,直接切换
|
||||
logger.debug('DynamicNewsCard', '无需加载,直接切换', {
|
||||
currentPage,
|
||||
newPage,
|
||||
reason: '所有页面均已缓存'
|
||||
});
|
||||
|
||||
// 只在组件仍挂载时更新状态
|
||||
if (isMountedRef.current) {
|
||||
setCurrentPage(newPage);
|
||||
}
|
||||
if (isTargetPageCached) {
|
||||
// 目标页已缓存,直接切换
|
||||
console.log(`%c🟡 [缓存] 目标页已缓存,直接切换到第${newPage}页`, 'color: #16A34A; font-weight: bold;');
|
||||
logger.debug('DynamicNewsCard', '目标页已缓存,直接切换', { newPage });
|
||||
setCurrentPage(newPage);
|
||||
} else {
|
||||
// 场景D: 意外分支(有缺失页面但 hasMore=false)
|
||||
logger.warn('DynamicNewsCard', '意外分支:有缺失页面但无法加载', {
|
||||
missingPages,
|
||||
hasMore,
|
||||
currentPage,
|
||||
newPage,
|
||||
total,
|
||||
cachedCount
|
||||
});
|
||||
// 目标页未缓存,显示 loading 并加载数据
|
||||
console.log(`%c🟡 [缓存] 目标页未缓存,需要加载第${newPage}页数据`, 'color: #DC2626; font-weight: bold;');
|
||||
logger.debug('DynamicNewsCard', '目标页未缓存,加载数据', { newPage });
|
||||
const success = await loadPage(newPage);
|
||||
|
||||
// 只在组件仍挂载时更新状态
|
||||
if (isMountedRef.current) {
|
||||
// 加载成功后切换页面
|
||||
if (success) {
|
||||
console.log(`%c🟢 [加载成功] 切换到第${newPage}页`, 'color: #16A34A; font-weight: bold;');
|
||||
setCurrentPage(newPage);
|
||||
|
||||
toast({
|
||||
title: '数据不完整',
|
||||
description: `第 ${newPage} 页数据可能不完整`,
|
||||
status: 'warning',
|
||||
duration: TOAST_CONFIG.DURATION_WARNING,
|
||||
isClosable: true,
|
||||
position: 'top'
|
||||
});
|
||||
} else {
|
||||
console.log(`%c❌ [加载失败] 未能切换到第${newPage}页`, 'color: #DC2626; font-weight: bold;');
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
hasMore,
|
||||
total,
|
||||
allCachedEvents.length,
|
||||
cachedCount,
|
||||
checkTargetPageCache,
|
||||
calculatePreloadRange,
|
||||
findMissingPages,
|
||||
loadPages,
|
||||
loadPage,
|
||||
dispatch,
|
||||
toast
|
||||
]);
|
||||
|
||||
// 更新累积列表(四排模式专用)
|
||||
useEffect(() => {
|
||||
if (isAccumulateMode) {
|
||||
// 计算已加载的所有事件(从第1页到当前页)
|
||||
const startIndex = 0;
|
||||
const endIndex = currentPage * pageSize;
|
||||
const accumulated = allCachedEvents.slice(startIndex, endIndex).filter(e => e !== null);
|
||||
|
||||
logger.debug('DynamicNewsCard', '更新累积事件列表', {
|
||||
currentPage,
|
||||
pageSize,
|
||||
startIndex,
|
||||
endIndex,
|
||||
accumulatedLength: accumulated.length
|
||||
});
|
||||
|
||||
setAccumulatedEvents(accumulated);
|
||||
} else {
|
||||
// 非累积模式时清空累积列表
|
||||
if (accumulatedEvents.length > 0) {
|
||||
setAccumulatedEvents([]);
|
||||
}
|
||||
}
|
||||
}, [isAccumulateMode, currentPage, pageSize, allCachedEvents]);
|
||||
|
||||
// 加载下一页(用于无限滚动)
|
||||
const loadNextPage = useCallback(async () => {
|
||||
if (currentPage >= totalPages || loadingPage !== null) {
|
||||
logger.debug('DynamicNewsCard', '无法加载下一页', {
|
||||
currentPage,
|
||||
totalPages,
|
||||
loadingPage,
|
||||
reason: currentPage >= totalPages ? '已是最后一页' : '正在加载中'
|
||||
});
|
||||
return Promise.resolve(false); // 没有更多数据或正在加载
|
||||
}
|
||||
|
||||
const nextPage = currentPage + 1;
|
||||
logger.debug('DynamicNewsCard', '懒加载:加载下一页', { currentPage, nextPage });
|
||||
|
||||
try {
|
||||
await handlePageChange(nextPage);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('DynamicNewsCard', '懒加载失败', error, { nextPage });
|
||||
return false;
|
||||
}
|
||||
}, [currentPage, totalPages, loadingPage, handlePageChange]);
|
||||
|
||||
// 模式切换处理
|
||||
const handleModeToggle = useCallback((newMode) => {
|
||||
if (newMode === mode) return;
|
||||
@@ -365,9 +288,20 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
||||
setMode(newMode);
|
||||
setCurrentPage(PAGINATION_CONFIG.INITIAL_PAGE);
|
||||
|
||||
const newPageSize = newMode === DISPLAY_MODES.CAROUSEL
|
||||
? PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE
|
||||
: PAGINATION_CONFIG.GRID_PAGE_SIZE;
|
||||
const newPageSize = (() => {
|
||||
switch (newMode) {
|
||||
case DISPLAY_MODES.CAROUSEL:
|
||||
return PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE;
|
||||
case DISPLAY_MODES.GRID:
|
||||
return PAGINATION_CONFIG.GRID_PAGE_SIZE;
|
||||
case DISPLAY_MODES.FOUR_ROW:
|
||||
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
|
||||
case DISPLAY_MODES.VERTICAL:
|
||||
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
|
||||
default:
|
||||
return PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE;
|
||||
}
|
||||
})();
|
||||
|
||||
// 检查第1页的数据是否完整(排除 null)
|
||||
const firstPageData = allCachedEvents.slice(0, newPageSize);
|
||||
@@ -395,9 +329,12 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
||||
totalPages,
|
||||
hasMore,
|
||||
currentPageEvents,
|
||||
displayEvents, // 新增:当前显示的事件列表(累积或分页)
|
||||
isAccumulateMode, // 新增:是否累积模式
|
||||
|
||||
// 方法
|
||||
handlePageChange,
|
||||
handleModeToggle
|
||||
handleModeToggle,
|
||||
loadNextPage // 新增:加载下一页(用于无限滚动)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ const CollapsibleHeader = ({ title, isOpen, onToggle, count = null }) => {
|
||||
<Heading size="sm" color={headingColor}>
|
||||
{title}
|
||||
</Heading>
|
||||
{count !== null && (
|
||||
{count !== null && count > 0 && (
|
||||
<Badge colorScheme="blue" borderRadius="full">
|
||||
{count}
|
||||
</Badge>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
|
||||
// 动态新闻详情面板主组件(组装所有子组件)
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
@@ -42,20 +42,28 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
||||
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
|
||||
|
||||
// 使用 Hook 获取实时数据
|
||||
// 使用 Hook 获取实时数据(禁用自动加载,改为手动触发)
|
||||
const {
|
||||
stocks,
|
||||
quotes,
|
||||
eventDetail,
|
||||
historicalEvents,
|
||||
expectationScore,
|
||||
loading
|
||||
} = useEventStocks(event?.id, event?.created_at);
|
||||
loading,
|
||||
loadStocksData,
|
||||
loadHistoricalData,
|
||||
loadChainAnalysis
|
||||
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false });
|
||||
|
||||
// 子区块折叠状态管理(默认折叠)+ 加载追踪
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(false);
|
||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
|
||||
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
|
||||
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
|
||||
|
||||
// 折叠状态管理
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(true);
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(true);
|
||||
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
||||
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
|
||||
|
||||
// 自选股管理(使用 localStorage)
|
||||
const [watchlistSet, setWatchlistSet] = useState(() => {
|
||||
@@ -67,6 +75,53 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 相关股票 - 展开时加载
|
||||
const handleStocksToggle = useCallback(() => {
|
||||
const newState = !isStocksOpen;
|
||||
setIsStocksOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedStocks) {
|
||||
console.log('%c📊 [相关股票] 首次展开,加载股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
||||
loadStocksData();
|
||||
setHasLoadedStocks(true);
|
||||
}
|
||||
}, [isStocksOpen, hasLoadedStocks, loadStocksData, event?.id]);
|
||||
|
||||
// 历史事件对比 - 展开时加载
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
const newState = !isHistoricalOpen;
|
||||
setIsHistoricalOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedHistorical) {
|
||||
console.log('%c📜 [历史事件] 首次展开,加载历史事件数据', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
|
||||
loadHistoricalData();
|
||||
setHasLoadedHistorical(true);
|
||||
}
|
||||
}, [isHistoricalOpen, hasLoadedHistorical, loadHistoricalData, event?.id]);
|
||||
|
||||
// 传导链分析 - 展开时加载
|
||||
const handleTransmissionToggle = useCallback(() => {
|
||||
const newState = !isTransmissionOpen;
|
||||
setIsTransmissionOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedTransmission) {
|
||||
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
|
||||
loadChainAnalysis();
|
||||
setHasLoadedTransmission(true);
|
||||
}
|
||||
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
|
||||
|
||||
// 事件切换时重置所有子模块状态
|
||||
useEffect(() => {
|
||||
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
|
||||
setIsStocksOpen(false);
|
||||
setHasLoadedStocks(false);
|
||||
setIsHistoricalOpen(false);
|
||||
setHasLoadedHistorical(false);
|
||||
setIsTransmissionOpen(false);
|
||||
setHasLoadedTransmission(false);
|
||||
}, [event?.id]);
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
@@ -148,29 +203,36 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
eventTime={event.created_at}
|
||||
/>
|
||||
|
||||
{/* 相关股票(可折叠) */}
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" />
|
||||
<Text ml={2} color={textColor}>加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<RelatedStocksSection
|
||||
stocks={stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
watchlistSet={watchlistSet}
|
||||
isOpen={isStocksOpen}
|
||||
onToggle={() => setIsStocksOpen(!isStocksOpen)}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
)}
|
||||
{/* 相关股票(可折叠) - 懒加载 */}
|
||||
<CollapsibleSection
|
||||
title="相关股票"
|
||||
isOpen={isStocksOpen}
|
||||
onToggle={handleStocksToggle}
|
||||
count={stocks?.length || 0}
|
||||
>
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" />
|
||||
<Text ml={2} color={textColor}>加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<RelatedStocksSection
|
||||
stocks={stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
watchlistSet={watchlistSet}
|
||||
isOpen={true} // 内部始终展开
|
||||
onToggle={() => {}} // 空函数
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 历史事件对比(可折叠) */}
|
||||
{/* 历史事件对比(可折叠) - 懒加载 */}
|
||||
<CollapsibleSection
|
||||
title="历史事件对比"
|
||||
isOpen={isHistoricalOpen}
|
||||
onToggle={() => setIsHistoricalOpen(!isHistoricalOpen)}
|
||||
onToggle={handleHistoricalToggle}
|
||||
count={historicalEvents?.length || 0}
|
||||
>
|
||||
{loading.historicalEvents ? (
|
||||
@@ -181,15 +243,16 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
) : (
|
||||
<HistoricalEvents
|
||||
events={historicalEvents || []}
|
||||
expectationScore={expectationScore}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 传导链分析(可折叠) */}
|
||||
{/* 传导链分析(可折叠) - 懒加载 */}
|
||||
<CollapsibleSection
|
||||
title="传导链分析"
|
||||
isOpen={isTransmissionOpen}
|
||||
onToggle={() => setIsTransmissionOpen(!isTransmissionOpen)}
|
||||
onToggle={handleTransmissionToggle}
|
||||
>
|
||||
<TransmissionChainAnalysis
|
||||
eventId={event.id}
|
||||
|
||||
@@ -137,11 +137,48 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<SimpleGrid columns={{ base: 1 }} spacing={2}>
|
||||
{concept.stocks.slice(0, 4).map((stock, idx) => (
|
||||
<ConceptStockItem key={idx} stock={stock} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{/* 可滚动容器 - 默认显示4条,可滚动查看全部 */}
|
||||
<Box
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
pr={2}
|
||||
onWheel={(e) => {
|
||||
const element = e.currentTarget;
|
||||
const scrollTop = element.scrollTop;
|
||||
const scrollHeight = element.scrollHeight;
|
||||
const clientHeight = element.clientHeight;
|
||||
|
||||
// 如果在滚动范围内,阻止事件冒泡到父容器
|
||||
if (
|
||||
(e.deltaY < 0 && scrollTop > 0) || // 向上滚动且未到顶部
|
||||
(e.deltaY > 0 && scrollTop + clientHeight < scrollHeight) // 向下滚动且未到底部
|
||||
) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
css={{
|
||||
overscrollBehavior: 'contain', // 防止滚动链
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f1f1',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#888',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: '#555',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1 }} spacing={2}>
|
||||
{concept.stocks.map((stock, idx) => (
|
||||
<ConceptStockItem key={idx} stock={stock} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
@@ -227,6 +227,16 @@ const StockListItem = ({
|
||||
{isDescExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 合规提示 */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
mt={2}
|
||||
fontStyle="italic"
|
||||
>
|
||||
⚠️ 以上关联描述由AI生成,仅供参考,不构成投资建议
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// src/views/Community/components/EventCard/HorizontalDynamicNewsEventCard.js
|
||||
// 横向布局的动态新闻事件卡片组件(时间在左,卡片在右)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
Box,
|
||||
Text,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
PopoverArrow,
|
||||
Portal,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { getImportanceConfig, getAllImportanceLevels } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
import EventTimeline from './EventTimeline';
|
||||
import EventFollowButton from './EventFollowButton';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
|
||||
/**
|
||||
* 横向布局的动态新闻事件卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @param {number} props.index - 事件索引
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {boolean} props.isSelected - 是否被选中
|
||||
* @param {Function} props.onEventClick - 卡片点击事件
|
||||
* @param {Function} props.onTitleClick - 标题点击事件
|
||||
* @param {Function} props.onToggleFollow - 切换关注事件
|
||||
* @param {Object} props.timelineStyle - 时间轴样式配置
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
*/
|
||||
const HorizontalDynamicNewsEventCard = ({
|
||||
event,
|
||||
index,
|
||||
isFollowing,
|
||||
followerCount,
|
||||
isSelected = false,
|
||||
onEventClick,
|
||||
onTitleClick,
|
||||
onToggleFollow,
|
||||
timelineStyle,
|
||||
borderColor,
|
||||
}) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={3} w="full">
|
||||
{/* 左侧时间轴 */}
|
||||
<EventTimeline
|
||||
createdAt={event.created_at}
|
||||
timelineStyle={timelineStyle}
|
||||
borderColor={borderColor}
|
||||
minHeight="60px"
|
||||
/>
|
||||
|
||||
{/* 右侧事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
position="relative"
|
||||
bg={isSelected
|
||||
? useColorModeValue('blue.50', 'blue.900')
|
||||
: (index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750'))
|
||||
}
|
||||
borderWidth={isSelected ? "2px" : "1px"}
|
||||
borderColor={isSelected
|
||||
? useColorModeValue('blue.500', 'blue.400')
|
||||
: borderColor
|
||||
}
|
||||
borderRadius="md"
|
||||
boxShadow={isSelected ? "lg" : "sm"}
|
||||
overflow="hidden"
|
||||
_hover={{
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: isSelected ? 'blue.600' : importance.color,
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick?.(event)}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
{/* 左上角:重要性矩形角标(镂空边框样式) */}
|
||||
<Popover trigger="hover" placement="right" isLazy>
|
||||
<PopoverTrigger>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
zIndex={1}
|
||||
bg="transparent"
|
||||
color={importance.badgeBg}
|
||||
borderWidth="2px"
|
||||
borderColor={importance.badgeBg}
|
||||
fontSize="11px"
|
||||
fontWeight="bold"
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
minW="auto"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
lineHeight="1"
|
||||
borderBottomRightRadius="md"
|
||||
cursor="help"
|
||||
>
|
||||
{importance.label}
|
||||
</Box>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent width="auto" maxW="350px">
|
||||
<PopoverArrow />
|
||||
<PopoverBody p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={1}>
|
||||
重要性等级说明
|
||||
</Text>
|
||||
{getAllImportanceLevels().map(item => (
|
||||
<HStack key={item.level} spacing={2} align="flex-start">
|
||||
<Box
|
||||
w="20px"
|
||||
h="20px"
|
||||
borderWidth="2px"
|
||||
borderColor={item.badgeBg}
|
||||
color={item.badgeBg}
|
||||
fontSize="9px"
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
{item.level}
|
||||
</Box>
|
||||
<Text fontSize="xs" flex={1}>
|
||||
<Text as="span" fontWeight="bold">
|
||||
{item.label}:
|
||||
</Text>
|
||||
{item.description}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
|
||||
{/* 右上角:关注按钮 */}
|
||||
<Box position="absolute" top={2} right={2} zIndex={1}>
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={() => onToggleFollow?.(event.id)}
|
||||
size="xs"
|
||||
showCount={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* 标题 - 最多两行,添加上边距避免与角标重叠 */}
|
||||
<Box
|
||||
cursor="pointer"
|
||||
onClick={(e) => onTitleClick?.(e, event)}
|
||||
mt={1}
|
||||
paddingRight="10px"
|
||||
>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="semibold"
|
||||
color={linkColor}
|
||||
lineHeight="1.4"
|
||||
noOfLines={2}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 第二行:涨跌幅数据 */}
|
||||
<StockChangeIndicators
|
||||
avgChange={event.related_avg_chg}
|
||||
maxChange={event.related_max_chg}
|
||||
weekChange={event.related_week_chg}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default HorizontalDynamicNewsEventCard;
|
||||
@@ -63,11 +63,11 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
refreshQuotes
|
||||
} = useEventStocks(event?.id, event?.start_time);
|
||||
|
||||
// 自选股管理
|
||||
// 自选股管理(只在 Drawer 可见时加载)
|
||||
const {
|
||||
watchlistSet,
|
||||
toggleWatchlist
|
||||
} = useWatchlist();
|
||||
} = useWatchlist(visible);
|
||||
|
||||
// 实时监控管理
|
||||
const {
|
||||
|
||||
@@ -17,9 +17,11 @@ import { logger } from '../../../../../utils/logger';
|
||||
*
|
||||
* @param {string} eventId - 事件ID
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {boolean} options.autoLoad - 是否自动加载数据(默认true)
|
||||
* @returns {Object} 事件数据和加载状态
|
||||
*/
|
||||
export const useEventStocks = (eventId, eventTime) => {
|
||||
export const useEventStocks = (eventId, eventTime, { autoLoad = true } = {}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 从 Redux 获取数据
|
||||
@@ -45,22 +47,43 @@ export const useEventStocks = (eventId, eventTime) => {
|
||||
// 加载状态
|
||||
const loading = useSelector(state => state.stock.loading, shallowEqual);
|
||||
|
||||
// 加载所有数据
|
||||
// 拆分加载函数 - 相关股票数据
|
||||
const loadStocksData = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载股票数据', { eventId });
|
||||
dispatch(fetchEventStocks({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 拆分加载函数 - 历史事件数据
|
||||
const loadHistoricalData = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载历史事件数据', { eventId });
|
||||
dispatch(fetchHistoricalEvents({ eventId }));
|
||||
dispatch(fetchExpectationScore({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 拆分加载函数 - 传导链分析数据
|
||||
const loadChainAnalysis = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载传导链数据', { eventId });
|
||||
dispatch(fetchChainAnalysis({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 加载所有数据(保留用于兼容性)
|
||||
const loadAllData = useCallback(() => {
|
||||
if (!eventId) {
|
||||
logger.warn('useEventStocks', 'eventId 为空,跳过数据加载');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('useEventStocks', '开始加载事件数据', { eventId });
|
||||
logger.debug('useEventStocks', '开始加载事件所有数据', { eventId });
|
||||
|
||||
// 并发加载所有数据
|
||||
dispatch(fetchEventStocks({ eventId }));
|
||||
dispatch(fetchEventDetail({ eventId }));
|
||||
dispatch(fetchHistoricalEvents({ eventId }));
|
||||
dispatch(fetchChainAnalysis({ eventId }));
|
||||
dispatch(fetchExpectationScore({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
loadStocksData();
|
||||
loadHistoricalData();
|
||||
loadChainAnalysis();
|
||||
}, [dispatch, eventId, loadStocksData, loadHistoricalData, loadChainAnalysis]);
|
||||
|
||||
// 强制刷新所有数据
|
||||
const refreshAllData = useCallback(() => {
|
||||
@@ -88,12 +111,16 @@ export const useEventStocks = (eventId, eventTime) => {
|
||||
dispatch(fetchStockQuotes({ codes, eventTime }));
|
||||
}, [dispatch, stocks, eventTime]);
|
||||
|
||||
// 自动加载事件数据
|
||||
// 自动加载事件数据(可通过 autoLoad 参数控制)
|
||||
useEffect(() => {
|
||||
if (eventId) {
|
||||
if (eventId && autoLoad) {
|
||||
logger.debug('useEventStocks', '自动加载已启用,加载所有数据', { eventId, autoLoad });
|
||||
loadAllData();
|
||||
} else if (eventId && !autoLoad) {
|
||||
logger.debug('useEventStocks', '自动加载已禁用,等待手动触发', { eventId, autoLoad });
|
||||
// 禁用自动加载时,不加载任何数据
|
||||
}
|
||||
}, [eventId]); // 修复:只依赖 eventId,避免无限循环
|
||||
}, [eventId, autoLoad, loadAllData]); // 添加 loadAllData 依赖
|
||||
|
||||
// 自动加载行情数据
|
||||
useEffect(() => {
|
||||
@@ -131,6 +158,9 @@ export const useEventStocks = (eventId, eventTime) => {
|
||||
|
||||
// 方法
|
||||
loadAllData,
|
||||
loadStocksData, // 新增:加载股票数据
|
||||
loadHistoricalData, // 新增:加载历史事件数据
|
||||
loadChainAnalysis, // 新增:加载传导链数据(重命名避免冲突)
|
||||
refreshAllData,
|
||||
refreshQuotes
|
||||
};
|
||||
|
||||
@@ -9,9 +9,10 @@ import { logger } from '../../../../../utils/logger';
|
||||
* 自选股管理 Hook
|
||||
* 封装自选股的加载、添加、移除逻辑
|
||||
*
|
||||
* @param {boolean} shouldLoad - 是否立即加载自选股列表(默认 true)
|
||||
* @returns {Object} 自选股数据和操作方法
|
||||
*/
|
||||
export const useWatchlist = () => {
|
||||
export const useWatchlist = (shouldLoad = true) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 从 Redux 获取自选股列表
|
||||
@@ -23,10 +24,13 @@ export const useWatchlist = () => {
|
||||
return new Set(watchlistArray);
|
||||
}, [watchlistArray]);
|
||||
|
||||
// 初始化时加载自选股列表
|
||||
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)
|
||||
useEffect(() => {
|
||||
dispatch(loadWatchlist());
|
||||
}, [dispatch]);
|
||||
if (shouldLoad) {
|
||||
logger.debug('useWatchlist', '条件加载自选股列表', { shouldLoad });
|
||||
dispatch(loadWatchlist());
|
||||
}
|
||||
}, [dispatch, shouldLoad]);
|
||||
|
||||
/**
|
||||
* 检查股票是否在自选股中
|
||||
|
||||
@@ -491,9 +491,29 @@ const UnifiedSearchBox = ({
|
||||
}
|
||||
|
||||
// 行业标签
|
||||
if (industryValue && industryValue.length > 0) {
|
||||
const industryLabel = industryValue.slice(1).join(' > ');
|
||||
tags.push({ key: 'industry', label: `行业: ${industryLabel}` });
|
||||
if (industryValue && industryValue.length > 0 && industryData) {
|
||||
// 递归查找每个层级的 label
|
||||
const findLabel = (code, data) => {
|
||||
for (const item of data) {
|
||||
if (code.startsWith(item.value)) {
|
||||
if(item.value === code){
|
||||
return item.label;
|
||||
}else {
|
||||
return findLabel(code, item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 只显示最后一级的 label
|
||||
const lastLevelCode = industryValue[industryValue.length - 1];
|
||||
const lastLevelLabel = findLabel(lastLevelCode, industryData);
|
||||
|
||||
tags.push({
|
||||
key: 'industry',
|
||||
label: `行业: ${lastLevelLabel}`
|
||||
});
|
||||
}
|
||||
|
||||
// 日期范围标签
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/hooks/useEventFilters.js
|
||||
// 事件筛选逻辑 Hook
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
@@ -31,78 +31,90 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
};
|
||||
});
|
||||
|
||||
// 🔧 使用 ref 存储最新的 filters,避免 useCallback 依赖 filters 导致重新创建
|
||||
const filtersRef = useRef(filters);
|
||||
useEffect(() => {
|
||||
filtersRef.current = filters;
|
||||
}, [filters]);
|
||||
|
||||
// 更新筛选参数 - 直接替换(由 UnifiedSearchBox 输出完整参数)
|
||||
const updateFilters = useCallback((newFilters) => {
|
||||
// 🔧 从 ref 读取最新的 filters,避免依赖 filters 状态
|
||||
const currentFilters = filtersRef.current;
|
||||
|
||||
logger.debug('useEventFilters', '🔄 【接收到onSearch回调】updateFilters 接收到完整参数', {
|
||||
newFilters: newFilters,
|
||||
oldFilters: filters,
|
||||
oldFilters: currentFilters,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 🎯 PostHog 追踪:搜索查询
|
||||
if (newFilters.q !== filters.q && newFilters.q) {
|
||||
if (newFilters.q !== currentFilters.q && newFilters.q) {
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query: newFilters.q,
|
||||
category: 'news',
|
||||
previous_query: filters.q || null,
|
||||
previous_query: currentFilters.q || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:排序变化
|
||||
if (newFilters.sort !== filters.sort) {
|
||||
if (newFilters.sort !== currentFilters.sort) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'sort',
|
||||
filter_value: newFilters.sort,
|
||||
previous_value: filters.sort,
|
||||
previous_value: currentFilters.sort,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:重要性筛选
|
||||
if (newFilters.importance !== filters.importance) {
|
||||
if (newFilters.importance !== currentFilters.importance) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'importance',
|
||||
filter_value: newFilters.importance,
|
||||
previous_value: filters.importance,
|
||||
previous_value: currentFilters.importance,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:时间范围筛选
|
||||
if (newFilters.date_range !== filters.date_range && newFilters.date_range) {
|
||||
if (newFilters.date_range !== currentFilters.date_range && newFilters.date_range) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'date_range',
|
||||
filter_value: newFilters.date_range,
|
||||
previous_value: filters.date_range || null,
|
||||
previous_value: currentFilters.date_range || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:行业筛选
|
||||
if (newFilters.industry_code !== filters.industry_code && newFilters.industry_code) {
|
||||
if (newFilters.industry_code !== currentFilters.industry_code && newFilters.industry_code) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'industry',
|
||||
filter_value: newFilters.industry_code,
|
||||
previous_value: filters.industry_code || null,
|
||||
previous_value: currentFilters.industry_code || null,
|
||||
});
|
||||
}
|
||||
|
||||
setFilters(newFilters);
|
||||
logger.debug('useEventFilters', '✅ setFilters 已调用 (React异步更新中...)');
|
||||
}, [filters, track]);
|
||||
}, [track]); // ✅ 只依赖 track,不依赖 filters
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = useCallback((page) => {
|
||||
// 🔧 从 ref 读取最新的 filters
|
||||
const currentFilters = filtersRef.current;
|
||||
|
||||
// 🎯 PostHog 追踪:翻页
|
||||
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
page,
|
||||
filters: {
|
||||
sort: filters.sort,
|
||||
importance: filters.importance,
|
||||
has_query: !!filters.q,
|
||||
sort: currentFilters.sort,
|
||||
importance: currentFilters.importance,
|
||||
has_query: !!currentFilters.q,
|
||||
},
|
||||
});
|
||||
|
||||
// 保持现有筛选条件,只更新页码
|
||||
updateFilters({ ...filters, page });
|
||||
}, [filters, updateFilters, track]);
|
||||
updateFilters({ ...currentFilters, page });
|
||||
}, [updateFilters, track]); // ✅ 移除 filters 依赖
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((event) => {
|
||||
|
||||
@@ -282,6 +282,14 @@ const HistoricalEvents = ({
|
||||
重要性: {event.importance}
|
||||
</Badge>
|
||||
)}
|
||||
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
|
||||
<Badge
|
||||
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
|
||||
size="sm"
|
||||
>
|
||||
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 事件描述 */}
|
||||
|
||||
Reference in New Issue
Block a user