Compare commits

...

30 Commits

Author SHA1 Message Date
zdl
be0c53b588 feat: 虚拟化网格组件通用化 │ │
│ │                                                                                                                                                   │ │
│ │ - 支持多列布局(columnsPerRow 参数,默认4列)                                                                                                     │ │
│ │ - 支持自定义卡片组件(CardComponent 参数)                                                                                                        │ │
│ │ - 根据列数动态调整间距(单列 gap=3,多列 gap=4)                                                                                                  │ │
│ │ - 更新注释和文档
2025-11-05 08:53:07 +08:00
zdl
de1b31c70e feat: git commit -m "feat: 简化分页逻辑并添加累积模式支持 │ │
│ │                                                                                                                                                   │ │
│ │ - 移除复杂的预加载逻辑(calculatePreloadRange、findMissingPages)                                                                                 │ │
│ │ - 添加累积显示模式(accumulatedEvents、isAccumulateMode)                                                                                         │ │
│ │ - 添加 displayEvents(累积或分页二选一)                                                                                                          │ │
│ │ - 添加 loadNextPage 方法用于无限滚动                                                                                                              │ │
│ │ - 支持4种显示模式的pageSize计算                                                                                                                   │ │
│ │ - 简化 handlePageChange 逻辑"
2025-11-05 08:42:10 +08:00
zdl
d96ebd6b8c feat: 创建无限滚动Hook │ │
│ │                                                                                                                                                   │ │
│ │ - 监听容器滚动事件                                                                                                                                │ │
│ │ - 距离底部阈值可配置(默认200px)                                                                                                                 │ │
│ │ - 自动触发onLoadMore回调                                                                                                                          │ │
│ │ - 支持加载状态管理
2025-11-05 08:39:28 +08:00
zdl
67127aa615 feat: 创建虚拟化四排网格组件 2025-11-05 08:32:54 +08:00
zdl
e7c495a8b1 feat: feat: 实现事件详情子模块懒加载useEventStocks添加 autoLoad 参数和分离加载函数 │ │
│ │   - DynamicNewsDetailPanel实现子模块折叠和懒加载
2025-11-05 08:29:44 +08:00
zdl
e0cfa6fab2 feat: 创建纵向模式的横向卡片组件 2025-11-05 08:26:05 +08:00
zdl
c51d3811e5 feat: 添加 @tanstack/react-virtual 依赖 2025-11-05 08:24:28 +08:00
zdl
8fe13c9fa4 feat: 概念股票列表支持滚动查看全部数据 2025-11-05 08:12:03 +08:00
zdl
e6c422887c feat:使用 ref 避免 filters 依赖导致回调重新创建 2025-11-05 08:11:30 +08:00
zdl
7e110111c4 feat: 添加 FOUR_ROW 和 VERTICAL 模式常量及页面大小配置 2025-11-05 08:09:44 +08:00
zdl
38d1b51af3 feat: 修改更新依赖 2025-11-04 20:19:01 +08:00
zdl
c7334191e5 feat: 调整mock数据 2025-11-04 20:17:56 +08:00
zdl
7fdc9e26af feat: 历史事件对比没数据数量展示0 2025-11-04 20:07:21 +08:00
zdl
7f01a391e0 feat: 关闭posthog日志 2025-11-04 19:51:41 +08:00
zdl
58db08ca22 feat: 历史事件添加涨幅字段 2025-11-04 19:50:32 +08:00
zdl
bf75f9b387 feat: 添加超预期的分提示 2025-11-04 19:39:46 +08:00
zdl
2a59e9edb2 feat: 添加合规提示 2025-11-04 19:26:18 +08:00
zdl
87476226c3 feat: 行业标签展示文字 2025-11-04 19:17:39 +08:00
zdl
76360102bb feat: 相关概念UI调整 2025-11-04 18:22:26 +08:00
zdl
1a3987afe0 feature: 重要性支持多选 2025-11-04 17:53:42 +08:00
zdl
a512f3bd7e feat: 添加缺失的图标文件(logo192.png, badge.png) 2025-11-04 17:46:53 +08:00
zdl
ffa6c2f761 pref: 优化 useEffect 依赖和清理逻辑 2025-11-04 16:01:56 +08:00
zdl
64a441b717 Merge branch 'feature_2025/1028_event' into feature_bugfix/251104_event
* feature_2025/1028_event:
  实现多选重要性,采用逗号分隔
2025-11-04 15:39:28 +08:00
zdl
5b9155a30c feat: 提取常量和 Hooks 到独立文件(已完成) 2025-11-04 15:38:54 +08:00
zdl
6e5eaa9089 feat: 添加serverworker注册事件 2025-11-04 15:34:17 +08:00
1ed54d7ee0 实现多选重要性,采用逗号分隔 2025-11-04 15:33:23 +08:00
zdl
8ed65b062b pref: 日志管理优化 2025-11-04 15:19:49 +08:00
zdl
868b4ccebc feat: 筛选添加收益率筛选 2025-11-04 15:19:24 +08:00
zdl
67981f21a2 feat:拆分 handlePageChange 为子函数(减少复杂度) 2025-11-04 15:05:25 +08:00
zdl
0a10270ab0 feat: 提取 usePagination Hook 2025-11-04 14:58:02 +08:00
30 changed files with 1566 additions and 430 deletions

9
app.py
View File

@@ -6599,8 +6599,15 @@ def api_get_events():
query = query.filter_by(status=event_status)
if event_type != 'all':
query = query.filter_by(event_type=event_type)
# 支持多个重要性级别筛选,用逗号分隔(如 importance=S,A
if importance != 'all':
query = query.filter_by(importance=importance)
if ',' in importance:
# 多个重要性级别
importance_list = [imp.strip() for imp in importance.split(',') if imp.strip()]
query = query.filter(Event.importance.in_(importance_list))
else:
# 单个重要性级别
query = query.filter_by(importance=importance)
if creator_id:
query = query.filter_by(creator_id=creator_id)
# 新增:行业代码过滤(申银万国行业分类)

View File

@@ -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",

BIN
public/badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -4,8 +4,24 @@
"icons": [
{
"src": "favicon.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
"sizes": "32x32",
"type": "image/png"
},
{
"src": "badge.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "badge"
},
{
"src": "logo192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "apple-icon.png",
"sizes": "32x32",
"type": "image/png"
}
],
"start_url": "./index.html",

92
public/service-worker.js Normal file
View File

@@ -0,0 +1,92 @@
// public/service-worker.js
/**
* Service Worker for Browser Notifications
* 主要功能:支持浏览器通知的稳定运行
*/
const CACHE_NAME = 'valuefrontier-v1';
// Service Worker 安装事件
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
// 跳过等待,立即激活
self.skipWaiting();
});
// Service Worker 激活事件
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
// 立即接管所有页面
event.waitUntil(self.clients.claim());
});
// 通知点击事件
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] Notification clicked:', event.notification.tag);
event.notification.close();
// 获取通知数据中的链接
const urlToOpen = event.notification.data?.link;
if (urlToOpen) {
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((windowClients) => {
// 查找是否已有打开的窗口
for (let client of windowClients) {
if (client.url.includes(window.location.origin) && 'focus' in client) {
// 聚焦现有窗口并导航到目标页面
return client.focus().then(client => {
return client.navigate(urlToOpen);
});
}
}
// 如果没有打开的窗口,打开新窗口
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
}
});
// 通知关闭事件
self.addEventListener('notificationclose', (event) => {
console.log('[Service Worker] Notification closed:', event.notification.tag);
});
// Fetch 事件 - 基础的网络优先策略
self.addEventListener('fetch', (event) => {
// 对于通知相关的资源,使用网络优先策略
event.respondWith(
fetch(event.request)
.catch(() => {
// 网络失败时,尝试从缓存获取
return caches.match(event.request);
})
);
});
// 推送消息事件(预留,用于未来的 Push API 集成)
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push message received:', event);
if (event.data) {
const data = event.data.json();
const options = {
body: data.body || '您有新消息',
icon: data.icon || '/favicon.png',
badge: '/favicon.png',
data: data.data || {},
requireInteraction: data.requireInteraction || false,
tag: data.tag || `notification_${Date.now()}`,
};
event.waitUntil(
self.registration.showNotification(data.title || '价值前沿', options)
);
}
});
console.log('[Service Worker] Loaded successfully');

View File

@@ -11,6 +11,49 @@ import './styles/brainwave-colors.css';
// Import the main App component
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) {
// 在页面加载完成后注册
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then((registration) => {
console.log('[App] Service Worker registered successfully:', registration.scope);
// 监听更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('[App] Service Worker update found');
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('[App] Service Worker activated');
}
});
}
});
})
.catch((error) => {
console.error('[App] Service Worker registration failed:', error);
});
});
} else {
console.warn('[App] Service Worker is not supported in this browser');
}
}
// 启动 Mock Service Worker如果启用
async function startApp() {
// 只在开发环境启动 MSW
@@ -35,6 +78,9 @@ async function startApp() {
</Router>
</React.StrictMode>
);
// 注册 Service Worker
registerServiceWorker();
}
// 启动应用

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -130,7 +130,7 @@ export const accountHandlers = [
);
}
console.log('[Mock] 获取自选股列表');
// console.log('[Mock] 获取自选股列表'); // 已关闭:减少日志
return HttpResponse.json({
success: true,

View File

@@ -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);

View File

@@ -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) {

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsCard.js
// 横向滚动事件卡片组件(实时要闻·动态追踪)
import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react';
import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Card,
@@ -24,6 +24,8 @@ import EventScrollList from './DynamicNewsCard/EventScrollList';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
import UnifiedSearchBox from './UnifiedSearchBox';
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
import { usePagination } from './DynamicNewsCard/hooks/usePagination';
import { PAGINATION_CONFIG } from './DynamicNewsCard/constants';
/**
* 实时要闻·动态追踪 - 事件展示卡片组件
@@ -69,290 +71,63 @@ const DynamicNewsCard = forwardRef(({
// 本地状态
const [selectedEvent, setSelectedEvent] = useState(null);
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排
const [currentPage, setCurrentPage] = useState(1); // 当前页码
const [loadingPage, setLoadingPage] = useState(null); // 正在加载的目标页码(用于 UX 提示)
// 根据模式决定每页显示数量
const pageSize = mode === 'carousel' ? 5 : 10;
// 初始化标记 - 确保初始加载只执行一次
const hasInitialized = useRef(false);
// 计算总页数(基于服务端总数据量)
const totalPages = Math.ceil(total / pageSize) || 1;
// 使用分页 Hook
const {
currentPage,
mode,
loadingPage,
pageSize,
totalPages,
hasMore,
currentPageEvents,
handlePageChange,
handleModeToggle
} = usePagination({
allCachedEvents,
total,
cachedCount,
dispatch,
toast
});
// 检查是否还有更多数据
const hasMore = cachedCount < total;
// 从缓存中切片获取当前页数据(过滤 null 占位符)
const currentPageEvents = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
}, [allCachedEvents, currentPage, pageSize]);
// 翻页处理(智能预加载)
const handlePageChange = useCallback(async (newPage) => {
// 🔍 诊断日志 - 记录翻页开始状态
console.log('[handlePageChange] 开始翻页', {
currentPage,
newPage,
pageSize,
totalPages,
hasMore,
total,
allCachedEventsLength: allCachedEvents.length,
cachedCount
});
// 0. 首先检查目标页数据是否已完整缓存
const targetPageStartIndex = (newPage - 1) * pageSize;
const targetPageEndIndex = targetPageStartIndex + pageSize;
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
const validTargetData = targetPageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
const isTargetPageCached = validTargetData.length >= expectedCount;
console.log('[handlePageChange] 目标页缓存检查', {
newPage,
targetPageStartIndex,
targetPageEndIndex,
targetPageDataLength: targetPageData.length,
validTargetDataLength: validTargetData.length,
expectedCount,
isTargetPageCached
});
// 1. 判断翻页类型:连续翻页(上一页/下一页)还是跳转翻页(点击页码/输入跳转)
const isSequentialNavigation = Math.abs(newPage - currentPage) === 1;
// 2. 计算预加载范围
let preloadRange;
if (isSequentialNavigation) {
// 连续翻页前后各2页共5页
const start = Math.max(1, newPage - 2);
const end = Math.min(totalPages, newPage + 2);
preloadRange = Array.from(
{ length: end - start + 1 },
(_, i) => start + i
);
} else {
// 跳转翻页:只加载当前页
preloadRange = [newPage];
}
// 3. 检查哪些页面的数据还未缓存(检查是否包含 null 或超出数组长度)
const missingPages = preloadRange.filter(page => {
const pageStartIndex = (page - 1) * pageSize;
const pageEndIndex = pageStartIndex + pageSize;
// 如果该页超出数组范围,说明未缓存
if (pageEndIndex > allCachedEvents.length) {
console.log(`[missingPages] 页面${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;
console.log(`[missingPages] 页面${page}检查`, {
pageStartIndex,
pageEndIndex,
pageDataLength: pageData.length,
validDataLength: validData.length,
expectedCount,
hasNullOrIncomplete
});
return hasNullOrIncomplete;
});
console.log('[handlePageChange] 缺失页面检测完成', {
preloadRange,
missingPages,
missingPagesCount: missingPages.length
});
// 4. 如果目标页已缓存,立即切换页码,然后在后台静默预加载其他页
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
console.log('[DynamicNewsCard] 目标页已缓存,立即切换', {
currentPage,
newPage,
缺失页面: missingPages,
目标页已缓存: true
});
// 立即切换页码(用户无感知延迟)
setCurrentPage(newPage);
// 在后台静默预加载其他缺失页面(拆分为单页请求)
try {
console.log('[DynamicNewsCard] 开始后台预加载', {
缺失页面: missingPages,
每页数量: pageSize
});
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize,
clearCache: false
})).unwrap();
console.log(`[DynamicNewsCard] 后台预加载第 ${page} 页完成`);
}
console.log('[DynamicNewsCard] 后台预加载全部完成', {
预加载页面: missingPages
});
} catch (error) {
console.error('[DynamicNewsCard] 后台预加载失败', error);
// 静默失败,不影响用户体验
}
return; // 提前返回,不执行下面的加载逻辑
}
// 5. 如果目标页未缓存,显示 loading 并等待加载完成
if (missingPages.length > 0 && hasMore) {
console.log('[DynamicNewsCard] 目标页未缓存显示loading', {
currentPage,
newPage,
翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页',
预加载范围: preloadRange,
缺失页面: missingPages,
每页数量: pageSize,
目标页已缓存: false
});
try {
// 设置加载状态(显示"正在加载第X页..."
setLoadingPage(newPage);
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
console.log(`[DynamicNewsCard] 开始加载第 ${page}`);
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize, // 传递原始 pageSize用于正确计算索引
clearCache: false
})).unwrap();
console.log(`[DynamicNewsCard] 第 ${page} 页加载完成`);
}
console.log('[DynamicNewsCard] 所有缺失页面加载完成', {
缺失页面: missingPages
});
// 数据加载成功后才更新当前页码
setCurrentPage(newPage);
} catch (error) {
console.error('[DynamicNewsCard] 翻页加载失败', error);
// 显示错误提示
toast({
title: '加载失败',
description: `无法加载第 ${newPage} 页数据,请稍后重试`,
status: 'error',
duration: 3000,
isClosable: true,
position: 'top'
});
// 加载失败时不更新页码,保持在当前页
} finally {
// 清除加载状态
setLoadingPage(null);
}
} else if (missingPages.length === 0) {
// 只有在确实不需要加载时才直接切换
console.log('[handlePageChange] 无需加载,直接切换', {
currentPage,
newPage,
preloadRange,
missingPages,
reason: '所有页面均已缓存'
});
setCurrentPage(newPage);
} else {
// 理论上不应该到这里missingPages.length > 0 但 hasMore=false
console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', {
missingPages,
hasMore,
currentPage,
newPage,
total,
cachedCount
});
// 尝试切换页码,但可能会显示空数据
setCurrentPage(newPage);
toast({
title: '数据不完整',
description: `${newPage} 页数据可能不完整`,
status: 'warning',
duration: 2000,
isClosable: true,
position: 'top'
});
}
}, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]);
// 模式切换处理
const handleModeToggle = useCallback((newMode) => {
if (newMode === mode) return;
setMode(newMode);
setCurrentPage(1);
const newPageSize = newMode === 'carousel' ? 5 : 10;
// 检查第1页的数据是否完整排除 null
const firstPageData = allCachedEvents.slice(0, newPageSize);
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
if (needsRefetch) {
// 第1页数据不完整清空缓存重新请求
dispatch(fetchDynamicNews({
page: 1,
per_page: newPageSize,
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
// 如果第1页数据完整不发起请求直接切换
}, [mode, allCachedEvents, total, dispatch]);
// 初始加载
// 初始加载 - 只在组件首次挂载且未初始化时执行
useEffect(() => {
if (allCachedEvents.length === 0) {
if (!hasInitialized.current && allCachedEvents.length === 0) {
hasInitialized.current = true;
dispatch(fetchDynamicNews({
page: 1,
per_page: 5,
pageSize: 5, // 传递 pageSize 确保索引计算一致
page: PAGINATION_CONFIG.INITIAL_PAGE,
per_page: PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE,
pageSize: PAGINATION_CONFIG.CAROUSEL_PAGE_SIZE, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
}, [dispatch, allCachedEvents.length]);
// 默认选中第一个事件
// 默认选中第一个事件 - 只在当前选中的事件不在当前页时重置
useEffect(() => {
if (currentPageEvents.length > 0 && !selectedEvent) {
setSelectedEvent(currentPageEvents[0]);
if (currentPageEvents.length > 0) {
// 检查当前选中的事件是否在当前页中
const selectedEventInCurrentPage = currentPageEvents.find(
e => e.id === selectedEvent?.id
);
// 如果选中的事件不在当前页,则选中当前页第一个事件
if (!selectedEventInCurrentPage) {
setSelectedEvent(currentPageEvents[0]);
}
}
}, [currentPageEvents, selectedEvent]);
}, [currentPageEvents, selectedEvent?.id]);
// 组件卸载时清理选中状态
useEffect(() => {
return () => {
setSelectedEvent(null);
};
}, []);
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>

View File

@@ -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;

View File

@@ -0,0 +1,28 @@
// src/views/Community/components/DynamicNewsCard/constants.js
// 动态新闻卡片组件 - 常量配置
// ========== 分页配置常量 ==========
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页
};
// ========== 显示模式常量 ==========
export const DISPLAY_MODES = {
CAROUSEL: 'carousel', // 单排轮播模式
GRID: 'grid', // 双排网格模式
FOUR_ROW: 'four-row', // 四排网格模式
VERTICAL: 'vertical', // 纵向分栏模式
};
export const DEFAULT_MODE = DISPLAY_MODES.CAROUSEL;
// ========== Toast 提示配置 ==========
export const TOAST_CONFIG = {
DURATION_ERROR: 3000, // 错误提示持续时间(毫秒)
DURATION_WARNING: 2000, // 警告提示持续时间(毫秒)
};

View File

@@ -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 };
};

View File

@@ -0,0 +1,340 @@
// src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
// 分页逻辑自定义 Hook
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { fetchDynamicNews } from '../../../../../store/slices/communityDataSlice';
import { logger } from '../../../../../utils/logger';
import {
PAGINATION_CONFIG,
DISPLAY_MODES,
DEFAULT_MODE,
TOAST_CONFIG
} from '../constants';
/**
* 分页逻辑自定义 Hook
* @param {Object} options - Hook 配置选项
* @param {Array} options.allCachedEvents - 完整缓存事件列表
* @param {number} options.total - 服务端总数量
* @param {number} options.cachedCount - 已缓存数量
* @param {Function} options.dispatch - Redux dispatch 函数
* @param {Function} options.toast - Toast 通知函数
* @returns {Object} 分页状态和方法
*/
export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, toast }) => {
// 本地状态
const [currentPage, setCurrentPage] = useState(PAGINATION_CONFIG.INITIAL_PAGE);
const [loadingPage, setLoadingPage] = useState(null);
const [mode, setMode] = useState(DEFAULT_MODE);
// 累积显示的事件列表(用于四排模式的无限滚动)
const [accumulatedEvents, setAccumulatedEvents] = useState([]);
// 根据模式决定每页显示数量
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;
// 检查是否还有更多数据
const hasMore = cachedCount < total;
// 判断是否使用累积模式(四排模式)
const isAccumulateMode = mode === DISPLAY_MODES.FOUR_ROW;
// 从缓存中切片获取当前页数据(过滤 null 占位符)
const currentPageEvents = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
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 - 目标页码
* @returns {Object} { isTargetPageCached, targetPageInfo }
*/
const checkTargetPageCache = useCallback((targetPage) => {
const targetPageStartIndex = (targetPage - 1) * pageSize;
const targetPageEndIndex = targetPageStartIndex + pageSize;
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
const validTargetData = targetPageData.filter(e => e !== null);
// 修复:确保 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', '目标页缓存检查', {
targetPage,
targetPageStartIndex,
targetPageEndIndex,
targetPageDataLength: targetPageData.length,
validTargetDataLength: validTargetData.length,
expectedCount,
isTargetPageCached
});
return {
isTargetPageCached,
targetPageInfo: {
startIndex: targetPageStartIndex,
endIndex: targetPageEndIndex,
validCount: validTargetData.length,
expectedCount
}
};
}, [allCachedEvents, pageSize, total]);
// 已删除: calculatePreloadRange不再需要预加载
// 已删除: findMissingPages不再需要查找缺失页面
/**
* 加载单个页面数据
* @param {number} targetPage - 目标页码
* @returns {Promise<boolean>} 是否加载成功
*/
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', '开始加载页面数据', {
targetPage,
pageSize
});
await dispatch(fetchDynamicNews({
page: targetPage,
per_page: pageSize,
pageSize: pageSize,
clearCache: false
})).unwrap();
console.log(`%c🟢 [API请求] 第${targetPage}页加载完成`, 'color: #16A34A; font-weight: bold;');
logger.debug('DynamicNewsCard', `${targetPage} 页加载完成`);
return true;
} catch (error) {
logger.error('DynamicNewsCard', 'loadPage', error, {
targetPage
});
toast({
title: '加载失败',
description: `无法加载第 ${targetPage} 页数据,请稍后重试`,
status: 'error',
duration: TOAST_CONFIG.DURATION_ERROR,
isClosable: true,
position: 'top'
});
return false;
} finally {
setLoadingPage(null);
}
}, [dispatch, pageSize, toast]);
// 翻页处理(简化版 - 无预加载)
const handlePageChange = useCallback(async (newPage) => {
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,
total,
allCachedEventsLength: allCachedEvents.length,
cachedCount
});
// 特殊处理:返回第一页 - 清空缓存重新加载
if (newPage === 1) {
logger.debug('DynamicNewsCard', '返回第一页,清空缓存重新加载');
setCurrentPage(1);
dispatch(fetchDynamicNews({
page: 1,
per_page: pageSize,
pageSize: pageSize,
clearCache: true // 清空缓存
}));
return;
}
// 检查目标页缓存状态
const { isTargetPageCached, targetPageInfo } = checkTargetPageCache(newPage);
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;');
if (isTargetPageCached) {
// 目标页已缓存,直接切换
console.log(`%c🟡 [缓存] 目标页已缓存,直接切换到第${newPage}`, 'color: #16A34A; font-weight: bold;');
logger.debug('DynamicNewsCard', '目标页已缓存,直接切换', { newPage });
setCurrentPage(newPage);
} else {
// 目标页未缓存,显示 loading 并加载数据
console.log(`%c🟡 [缓存] 目标页未缓存,需要加载第${newPage}页数据`, 'color: #DC2626; font-weight: bold;');
logger.debug('DynamicNewsCard', '目标页未缓存,加载数据', { newPage });
const success = await loadPage(newPage);
// 加载成功后切换页面
if (success) {
console.log(`%c🟢 [加载成功] 切换到第${newPage}`, 'color: #16A34A; font-weight: bold;');
setCurrentPage(newPage);
} else {
console.log(`%c❌ [加载失败] 未能切换到第${newPage}`, 'color: #DC2626; font-weight: bold;');
}
}
}, [
currentPage,
pageSize,
totalPages,
total,
allCachedEvents.length,
cachedCount,
checkTargetPageCache,
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;
setMode(newMode);
setCurrentPage(PAGINATION_CONFIG.INITIAL_PAGE);
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);
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
if (needsRefetch) {
// 第1页数据不完整清空缓存重新请求
dispatch(fetchDynamicNews({
page: 1,
per_page: newPageSize,
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
// 如果第1页数据完整不发起请求直接切换
}, [mode, allCachedEvents, total, dispatch]);
return {
// 状态
currentPage,
mode,
loadingPage,
pageSize,
totalPages,
hasMore,
currentPageEvents,
displayEvents, // 新增:当前显示的事件列表(累积或分页)
isAccumulateMode, // 新增:是否累积模式
// 方法
handlePageChange,
handleModeToggle,
loadNextPage // 新增:加载下一页(用于无限滚动)
};
};

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -3,7 +3,6 @@
import React from 'react';
import {
Box,
HStack,
Text,
useColorModeValue,
@@ -18,8 +17,6 @@ import moment from 'moment';
* @param {string|Object} props.eventTime - 事件发生时间
*/
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
if (!effectiveTradingDate) {
@@ -27,19 +24,17 @@ const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
}
return (
<Box mb={4} p={3} bg={sectionBg} borderRadius="md">
<HStack spacing={2}>
<FaCalendarAlt color="gray" />
<Text fontSize="sm" color={headingColor}>
涨跌幅数据日期{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据)
</Text>
)}
</Text>
</HStack>
</Box>
<HStack spacing={2}>
<FaCalendarAlt color="gray" size={12} />
<Text fontSize="xs" color={stockCountColor}>
涨跌幅数据{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据)
</Text>
)}
</Text>
</HStack>
);
};

View File

@@ -204,9 +204,16 @@ const RelatedConceptsSection = ({ eventTitle, effectiveTradingDate, eventTime })
<Box bg={sectionBg} p={3} borderRadius="md">
{/* 标题栏 */}
<Flex justify="space-between" align="center" mb={3}>
<Heading size="sm" color={headingColor}>
相关概念
</Heading>
<Flex align="center" gap={3}>
<Heading size="sm" color={headingColor}>
相关概念
</Heading>
{/* 交易日期信息 */}
<TradingDateInfo
effectiveTradingDate={effectiveTradingDate}
eventTime={eventTime}
/>
</Flex>
<Button
size="sm"
variant="ghost"
@@ -229,12 +236,7 @@ const RelatedConceptsSection = ({ eventTitle, effectiveTradingDate, eventTime })
/>
))}
</Flex>
{/* 交易日期信息 */}
<TradingDateInfo
effectiveTradingDate={effectiveTradingDate}
eventTime={eventTime}
/>
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>

View File

@@ -227,6 +227,16 @@ const StockListItem = ({
{isDescExpanded ? '收起' : '展开'}
</Button>
)}
{/* 合规提示 */}
<Text
fontSize="xs"
color="gray.500"
mt={2}
fontStyle="italic"
>
以上关联描述由AI生成仅供参考不构成投资建议
</Text>
</Box>
)}
</VStack>

View File

@@ -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;

View File

@@ -63,11 +63,11 @@ function StockDetailPanel({ visible, event, onClose }) {
refreshQuotes
} = useEventStocks(event?.id, event?.start_time);
// 自选股管理
// 自选股管理(只在 Drawer 可见时加载)
const {
watchlistSet,
toggleWatchlist
} = useWatchlist();
} = useWatchlist(visible);
// 实时监控管理
const {

View File

@@ -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
};

View File

@@ -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]);
/**
* 检查股票是否在自选股中

View File

@@ -34,7 +34,7 @@ const UnifiedSearchBox = ({
// 筛选条件状态
const [sort, setSort] = useState('new'); // 排序方式
const [importance, setImportance] = useState('all'); // 重要性
const [importance, setImportance] = useState([]); // 重要性(数组,支持多选)
const [dateRange, setDateRange] = useState(null); // 日期范围
// ✅ 本地输入状态 - 管理用户的实时输入
@@ -129,9 +129,22 @@ const UnifiedSearchBox = ({
useEffect(() => {
if (!filters) return;
// 初始化排序和重要性
// 初始化排序
if (filters.sort) setSort(filters.sort);
if (filters.importance) setImportance(filters.importance);
// 初始化重要性(字符串解析为数组)
if (filters.importance) {
const importanceArray = filters.importance === 'all'
? [] // 'all' 对应空数组(不显示任何选中)
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
setImportance(importanceArray);
logger.debug('UnifiedSearchBox', '初始化重要性', {
filters_importance: filters.importance,
importanceArray
});
} else {
setImportance([]);
}
// ✅ 初始化日期范围
if (filters.date_range) {
@@ -247,30 +260,28 @@ const UnifiedSearchBox = ({
}
};
// ✅ 重要性变化(使用防抖)
// ✅ 重要性变化(立即执行)- 支持多选
const handleImportanceChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】重要性值改变', {
logger.debug('UnifiedSearchBox', '重要性值改变', {
oldValue: importance,
newValue: value
});
setImportance(value);
// ⚠️ 注意:setState是异步的,此时importance仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
importance: importance, // 旧值
sort: sort,
dateRange: dateRange,
industryValue: industryValue
});
// 使用防抖搜索
const params = buildFilterParams({ importance: value });
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
debouncedSearchRef.current.cancel();
}
// 转换为逗号分隔字符串传给后端(空数组表示"全部"
const importanceStr = value.length === 0 ? 'all' : value.join(',');
// 立即触发搜索
const params = buildFilterParams({ importance: importanceStr });
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 排序变化(使用防抖)
@@ -388,10 +399,31 @@ const UnifiedSearchBox = ({
}
});
// 处理排序参数 - 将 returns_avg/returns_week 转换为 sort=returns + return_type
const sortValue = overrides.sort ?? sort;
let actualSort = sortValue;
let returnType;
if (sortValue === 'returns_avg') {
actualSort = 'returns';
returnType = 'avg';
} else if (sortValue === 'returns_week') {
actualSort = 'returns';
returnType = 'week';
}
// 处理重要性参数:数组转换为逗号分隔字符串
let importanceValue = overrides.importance ?? importance;
if (Array.isArray(importanceValue)) {
importanceValue = importanceValue.length === 0
? 'all'
: importanceValue.join(',');
}
const result = {
// 基础参数overrides 优先级高于本地状态)
sort: overrides.sort ?? sort,
importance: overrides.importance ?? importance,
sort: actualSort,
importance: importanceValue,
date_range: dateRange ? `${dateRange[0].format('YYYY-MM-DD')}${dateRange[1].format('YYYY-MM-DD')}` : '',
page: 1,
@@ -404,6 +436,11 @@ const UnifiedSearchBox = ({
...overrides
};
// 添加 return_type 参数(如果需要)
if (returnType) {
result.return_type = returnType;
}
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
return result;
}, [sort, importance, dateRange, filters.q, industryValue]);
@@ -427,7 +464,7 @@ const UnifiedSearchBox = ({
setStockOptions([]);
setIndustryValue([]);
setSort('new');
setImportance('all');
setImportance([]); // 改为空数组
setDateRange(null);
// 输出重置后的完整参数
@@ -435,7 +472,7 @@ const UnifiedSearchBox = ({
q: '',
industry_code: '',
sort: 'new',
importance: 'all',
importance: 'all', // 传给后端时转为'all'
date_range: '',
page: 1
};
@@ -454,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}`
});
}
// 日期范围标签
@@ -465,14 +522,20 @@ const UnifiedSearchBox = ({
tags.push({ key: 'date_range', label: `日期: ${dateLabel}` });
}
// 重要性标签(排除默认值 'all'
if (importance && importance !== 'all') {
tags.push({ key: 'importance', label: `重要性: ${importance}` });
// 重要性标签(多选合并显示为单个标签
if (importance && importance.length > 0) {
const importanceLabel = importance.map(imp => `${imp}`).join(', ');
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
}
// 排序标签(排除默认值 'new'
if (sort && sort !== 'new') {
const sortLabel = sort === 'hot' ? '最热' : sort === 'importance' ? '重要性' : sort;
let sortLabel;
if (sort === 'hot') sortLabel = '最热';
else if (sort === 'importance') sortLabel = '重要性';
else if (sort === 'returns_avg') sortLabel = '平均收益率';
else if (sort === 'returns_week') sortLabel = '周收益率';
else sortLabel = sort;
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
}
@@ -483,6 +546,11 @@ const UnifiedSearchBox = ({
const handleRemoveTag = (key) => {
logger.debug('UnifiedSearchBox', '移除标签', { key });
// 取消所有待执行的防抖搜索(避免旧的防抖覆盖删除操作)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
if (key === 'search') {
// 清除搜索关键词和输入框,立即触发搜索
setInputValue(''); // 清空输入框
@@ -500,8 +568,8 @@ const UnifiedSearchBox = ({
const params = buildFilterParams({ date_range: '' });
triggerSearch(params);
} else if (key === 'importance') {
// 重置重要性为默认值
setImportance('all');
// 重置重要性为空数组(传给后端为'all'
setImportance([]);
const params = buildFilterParams({ importance: 'all' });
triggerSearch(params);
} else if (key === 'sort') {
@@ -601,12 +669,14 @@ const UnifiedSearchBox = ({
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ width: 100 }}
style={{ width: 150 }}
size="middle"
placeholder="全部"
maxTagCount={3}
>
<Option value="all">全部</Option>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
@@ -663,6 +733,8 @@ const UnifiedSearchBox = ({
<Option value="new">最新</Option>
<Option value="hot">最热</Option>
<Option value="importance">重要性</Option>
<Option value="returns_avg">平均收益率</Option>
<Option value="returns_week">周收益率</Option>
</AntSelect>
</Space>
</Space>

View File

@@ -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) => {

View File

@@ -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>
{/* 事件描述 */}