From ddd6b2d4affce266b5af8dfb5bfba597d8744f8a Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 14 Nov 2025 19:04:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Socket=20=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E7=9A=84=E6=99=BA=E8=83=BD=E5=88=97=E8=A1=A8=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD=EF=BC=88=E5=B8=A6?= =?UTF-8?q?=E9=98=B2=E6=8A=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心改动: - 扩展 NotificationContext,添加事件更新回调注册机制 - VirtualizedFourRowGrid 添加 forwardRef 暴露 getScrollPosition 方法 - DynamicNewsCard 实现智能刷新逻辑(根据模式和滚动位置判断是否刷新) - Community 页面注册 Socket 回调自动触发刷新 - 创建 TypeScript 通用防抖工具函数(debounce.ts) - 集成防抖机制(2秒延迟),避免短时间内频繁请求 智能刷新策略: - 纵向模式 + 第1页:自动刷新列表 - 纵向模式 + 其他页:不刷新(避免打断用户) - 平铺模式 + 滚动在顶部:自动刷新列表 - 平铺模式 + 滚动不在顶部:仅显示 Toast 提示 防抖效果: - 短时间内收到多个新事件,只执行最后一次刷新 - 减少服务器压力,提升用户体验 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/contexts/NotificationContext.js | 52 ++++++++ src/utils/debounce.ts | 90 +++++++++++++ .../Community/components/DynamicNewsCard.js | 125 +++++++++++++++++- .../DynamicNewsCard/EventScrollList.js | 5 +- .../DynamicNewsCard/VirtualizedFourRowGrid.js | 35 ++++- .../components/DynamicNewsCard/constants.js | 18 +++ src/views/Community/index.js | 66 ++++++++- 7 files changed, 382 insertions(+), 9 deletions(-) create mode 100644 src/utils/debounce.ts diff --git a/src/contexts/NotificationContext.js b/src/contexts/NotificationContext.js index 8254a29d..07e2ccc2 100644 --- a/src/contexts/NotificationContext.js +++ b/src/contexts/NotificationContext.js @@ -64,6 +64,9 @@ export const NotificationProvider = ({ children }) => { const adaptEventToNotificationRef = useRef(null); const isFirstConnect = useRef(true); // 标记是否首次连接 + // ⚡ 事件更新回调列表(用于在收到 new_event 时通知其他组件刷新数据) + const eventUpdateCallbacks = useRef(new Set()); + // ⚡ 使用权限引导管理 Hook const { shouldShowGuide, markGuideAsShown } = usePermissionGuide(); @@ -160,6 +163,37 @@ export const NotificationProvider = ({ children }) => { }); }, []); + /** + * 注册事件更新回调(用于在收到新事件时通知其他组件刷新) + * @param {Function} callback - 回调函数,接收 eventData 参数 + * @returns {Function} 取消注册函数 + */ + const registerEventUpdateCallback = useCallback((callback) => { + eventUpdateCallbacks.current.add(callback); + logger.info('NotificationContext', 'Event update callback registered', { + totalCallbacks: eventUpdateCallbacks.current.size + }); + + // 返回取消注册函数 + return () => { + eventUpdateCallbacks.current.delete(callback); + logger.info('NotificationContext', 'Event update callback unregistered', { + totalCallbacks: eventUpdateCallbacks.current.size + }); + }; + }, []); + + /** + * 取消注册事件更新回调(已废弃,建议使用 registerEventUpdateCallback 返回的函数) + * @param {Function} callback - 要取消的回调函数 + */ + const unregisterEventUpdateCallback = useCallback((callback) => { + eventUpdateCallbacks.current.delete(callback); + logger.info('NotificationContext', 'Event update callback unregistered (manual)', { + totalCallbacks: eventUpdateCallbacks.current.size + }); + }, []); + /** * 请求浏览器通知权限 */ @@ -764,6 +798,21 @@ export const NotificationProvider = ({ children }) => { console.log('[NotificationContext] 准备添加通知到队列...'); addNotificationRef.current(notification); console.log('[NotificationContext] ✅ 通知已添加到队列'); + + // ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据) + if (eventUpdateCallbacks.current.size > 0) { + console.log(`[NotificationContext] 🔔 触发 ${eventUpdateCallbacks.current.size} 个事件更新回调...`); + eventUpdateCallbacks.current.forEach(callback => { + try { + callback(data); + } catch (error) { + logger.error('NotificationContext', 'Event update callback error', error); + console.error('[NotificationContext] ❌ 事件更新回调执行失败:', error); + } + }); + console.log('[NotificationContext] ✅ 所有事件更新回调已触发'); + } + console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;'); }); @@ -1040,6 +1089,9 @@ export const NotificationProvider = ({ children }) => { showWelcomeGuide, showCommunityGuide, showFirstFollowGuide, + // ⚡ 新增:事件更新回调注册方法 + registerEventUpdateCallback, + unregisterEventUpdateCallback, }; return ( diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 00000000..17d44386 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,90 @@ +// src/utils/debounce.ts +// 防抖工具函数(TypeScript 版本) + +/** + * 防抖函数返回类型 + * @template T - 原函数类型 + */ +export interface DebouncedFunction any> { + /** + * 执行防抖后的函数 + * @param args - 原函数的参数 + */ + (...args: Parameters): void; + + /** + * 取消待执行的函数调用 + */ + cancel: () => void; +} + +/** + * 防抖函数 - 延迟执行,短时间内多次调用只执行最后一次 + * + * 工作原理: + * 1. 调用防抖函数时,清除之前的定时器 + * 2. 设置新的定时器,延迟 delay 毫秒后执行 + * 3. 如果在延迟期间再次调用,重复步骤 1-2 + * 4. 只有最后一次调用会在延迟后实际执行 + * + * 使用场景: + * - 搜索框输入:用户停止输入后才发送请求 + * - 窗口 resize:窗口调整结束后才重新计算布局 + * - Socket 事件:短时间内收到多个事件,只处理最后一个 + * + * @template T - 函数类型(泛型约束:任意函数) + * @param {T} func - 要防抖的函数 + * @param {number} delay - 延迟时间(毫秒) + * @returns {DebouncedFunction} 防抖后的函数(带 cancel 方法) + * + * @example + * ```typescript + * // 示例 1:无参数函数 + * const debouncedSave = debounce(() => { + * console.log('保存数据'); + * }, 1000); + * + * debouncedSave(); // 1秒后执行 + * debouncedSave(); // 取消上次,重新计时 1 秒 + * debouncedSave.cancel(); // 取消执行 + * + * // 示例 2:带参数函数 + * const debouncedSearch = debounce((keyword: string) => { + * console.log('搜索:', keyword); + * }, 500); + * + * debouncedSearch('react'); // 500ms 后执行 + * debouncedSearch('redux'); // 取消上次,重新计时 + * ``` + */ +export function debounce any>( + func: T, + delay: number +): DebouncedFunction { + // 使用 NodeJS.Timeout 类型(支持浏览器和 Node 环境) + let timerId: ReturnType | null = null; + + // 防抖函数主体 + const debouncedFn = (...args: Parameters): void => { + // 清除之前的定时器(防抖核心逻辑) + if (timerId !== null) { + clearTimeout(timerId); + } + + // 设置新的定时器 + timerId = setTimeout(() => { + func(...args); + timerId = null; // 执行后重置定时器 ID + }, delay); + }; + + // 添加 cancel 方法(用于组件卸载时清理) + debouncedFn.cancel = (): void => { + if (timerId !== null) { + clearTimeout(timerId); + timerId = null; + } + }; + + return debouncedFn; +} diff --git a/src/views/Community/components/DynamicNewsCard.js b/src/views/Community/components/DynamicNewsCard.js index a114931a..aefd5f32 100644 --- a/src/views/Community/components/DynamicNewsCard.js +++ b/src/views/Community/components/DynamicNewsCard.js @@ -1,7 +1,7 @@ // src/views/Community/components/DynamicNewsCard.js // 横向滚动事件卡片组件(实时要闻·动态追踪) -import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef, useImperativeHandle } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Card, @@ -44,8 +44,9 @@ import { selectFourRowEventsWithLoading } from '../../../store/slices/communityDataSlice'; import { usePagination } from './DynamicNewsCard/hooks/usePagination'; -import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants'; +import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './DynamicNewsCard/constants'; import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme'; +import { debounce } from '../../../utils/debounce'; // 🔍 调试:渲染计数器 let dynamicNewsCardRenderCount = 0; @@ -84,6 +85,7 @@ const DynamicNewsCard = forwardRef(({ // Refs const cardHeaderRef = useRef(null); const cardBodyRef = useRef(null); + const virtualizedGridRef = useRef(null); // ⚡ VirtualizedFourRowGrid 的 ref(用于获取滚动位置) // 从 Redux 读取关注状态 const eventFollowStatus = useSelector(selectEventFollowStatus); @@ -208,6 +210,124 @@ const [currentMode, setCurrentMode] = useState('vertical'); setCurrentMode(mode); }, [mode]); + /** + * ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑) + * + * 此函数会被 debounce 包装,避免短时间内频繁刷新 + */ + const executeRefresh = useCallback(() => { + const state = { + mode, + currentPage: pagination?.current_page || 1, + }; + + console.log('[DynamicNewsCard] ⏰ executeRefresh() 执行(防抖延迟后)', state); + + if (mode === 'vertical') { + // ========== 纵向模式 ========== + // 只在第1页时刷新,避免打断用户浏览其他页 + if (state.currentPage === 1) { + console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 刷新列表'); + handlePageChange(1); // 清空缓存并刷新第1页 + toast({ + title: '检测到新事件', + status: 'info', + duration: 2000, + isClosable: true, + }); + } else { + console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`); + } + } else if (mode === 'four-row') { + // ========== 平铺模式 ========== + // 检查滚动位置,只有在顶部时才刷新 + const scrollPos = virtualizedGridRef.current?.getScrollPosition(); + + if (scrollPos?.isNearTop) { + // 用户在顶部 10% 区域,安全刷新 + console.log('[DynamicNewsCard] 平铺模式 + 滚动在顶部 → 刷新列表'); + handlePageChange(1); // 清空并刷新 + toast({ + title: '检测到新事件,已刷新', + status: 'info', + duration: 2000, + isClosable: true, + }); + } else { + // 用户不在顶部,显示提示但不自动刷新 + console.log('[DynamicNewsCard] 平铺模式 + 滚动不在顶部 → 仅提示,不刷新'); + toast({ + title: '有新事件发布', + description: '滚动到顶部查看', + status: 'info', + duration: 3000, + isClosable: true, + }); + } + } + }, [mode, pagination, handlePageChange, toast]); + + /** + * ⚡【防抖包装】创建防抖版本的刷新函数 + * + * 使用 useMemo 确保防抖函数在 executeRefresh 不变时保持引用稳定 + * 防抖延迟:REFRESH_DEBOUNCE_DELAY (2000ms) + * + * 效果:短时间内收到多个新事件,只执行最后一次刷新 + */ + const debouncedRefresh = useMemo( + () => debounce(executeRefresh, REFRESH_DEBOUNCE_DELAY), + [executeRefresh] + ); + + /** + * ⚡ 暴露方法给父组件(用于 Socket 自动刷新) + */ + useImperativeHandle(ref, () => ({ + /** + * 智能刷新方法(带防抖,避免频繁刷新) + * + * 调用此方法时: + * 1. 清除之前的定时器(如果有) + * 2. 设置新的定时器(延迟 REFRESH_DEBOUNCE_DELAY 后执行) + * 3. 如果在延迟期间再次调用,重复步骤 1-2 + * 4. 只有最后一次调用会在延迟后实际执行 executeRefresh() + */ + refresh: () => { + console.log('[DynamicNewsCard] 🔔 refresh() 被调用(设置防抖定时器)', { + mode, + currentPage: pagination?.current_page || 1, + debounceDelay: `${REFRESH_DEBOUNCE_DELAY}ms`, + }); + + // 调用防抖包装后的函数 + debouncedRefresh(); + }, + + /** + * 获取当前状态(用于调试) + */ + getState: () => ({ + mode, + currentPage: pagination?.current_page || 1, + totalPages: pagination?.total_pages || 1, + total: pagination?.total || 0, + loading, + }), + }), [mode, pagination, loading, debouncedRefresh]); + + /** + * ⚡【清理逻辑】组件卸载时取消待执行的防抖函数 + * + * 作用:避免组件卸载后仍然执行刷新操作(防止内存泄漏和潜在错误) + */ + useEffect(() => { + return () => { + console.log('[DynamicNewsCard] 🧹 组件卸载,取消待执行的防抖刷新'); + debouncedRefresh.cancel(); + }; + }, [debouncedRefresh]); + // 监听 error 状态,显示空数据提示 useEffect(() => { if (error && error.includes('暂无更多数据')) { @@ -578,6 +698,7 @@ const [currentMode, setCurrentMode] = useState('vertical'); eventFollowStatus={eventFollowStatus} onToggleFollow={handleToggleFollow} hasMore={hasMore} + virtualizedGridRef={virtualizedGridRef} // ⚡ 传递 ref 给 VirtualizedFourRowGrid /> diff --git a/src/views/Community/components/DynamicNewsCard/EventScrollList.js b/src/views/Community/components/DynamicNewsCard/EventScrollList.js index a9ea89f0..2243d0f0 100644 --- a/src/views/Community/components/DynamicNewsCard/EventScrollList.js +++ b/src/views/Community/components/DynamicNewsCard/EventScrollList.js @@ -28,6 +28,7 @@ import VerticalModeLayout from './VerticalModeLayout'; * @param {boolean} hasMore - 是否还有更多数据 * @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } } * @param {Function} onToggleFollow - 关注按钮回调 + * @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref(用于获取滚动位置) */ const EventScrollList = ({ events, @@ -46,7 +47,8 @@ const EventScrollList = ({ mode = 'vertical', hasMore = true, eventFollowStatus = {}, - onToggleFollow + onToggleFollow, + virtualizedGridRef }) => { const scrollContainerRef = useRef(null); @@ -111,6 +113,7 @@ const EventScrollList = ({ > {/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */} { +}, ref) => { const parentRef = useRef(null); const isLoadingMore = useRef(false); // 防止重复加载 const lastRefreshTime = useRef(0); // 记录上次刷新时间(用于30秒防抖) @@ -81,6 +81,31 @@ const VirtualizedFourRowGrid = ({ overscan: 2, // 预加载2行(上下各1行) }); + /** + * ⚡ 暴露方法给父组件(用于 Socket 刷新判断) + */ + useImperativeHandle(ref, () => ({ + /** + * 获取当前滚动位置信息 + * @returns {Object|null} 滚动位置信息 + */ + getScrollPosition: () => { + const scrollElement = parentRef.current; + if (!scrollElement) return null; + + const { scrollTop, scrollHeight, clientHeight } = scrollElement; + const isNearTop = scrollTop < clientHeight * 0.1; // 顶部 10% 区域 + + return { + scrollTop, + scrollHeight, + clientHeight, + isNearTop, + scrollPercentage: ((scrollTop + clientHeight) / scrollHeight) * 100, + }; + }, + }), []); + /** * 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新 * @@ -360,6 +385,8 @@ const VirtualizedFourRowGrid = ({ ); -}; +}); + +VirtualizedFourRowGrid.displayName = 'VirtualizedFourRowGrid'; export default VirtualizedFourRowGrid; diff --git a/src/views/Community/components/DynamicNewsCard/constants.js b/src/views/Community/components/DynamicNewsCard/constants.js index 85b3ce53..19ea7300 100644 --- a/src/views/Community/components/DynamicNewsCard/constants.js +++ b/src/views/Community/components/DynamicNewsCard/constants.js @@ -38,3 +38,21 @@ export const TOAST_CONFIG = { DURATION_ERROR: 3000, // 错误提示持续时间(毫秒) DURATION_WARNING: 2000, // 警告提示持续时间(毫秒) }; + +// ========== Socket 刷新防抖配置 ========== +/** + * Socket 新事件刷新防抖延迟(毫秒) + * + * 作用:避免短时间内收到多个新事件时频繁刷新列表 + * + * 场景示例: + * - 第 1 秒:收到新事件 → 延迟 2 秒刷新 + * - 第 2 秒:收到新事件 → 取消上次,重新延迟 2 秒 + * - 第 3 秒:收到新事件 → 取消上次,重新延迟 2 秒 + * - 第 5 秒:触发刷新 → 只发送 1 次 API 请求 + * + * 推荐值:2000ms (2 秒) + * - 太短(如 500ms)→ 仍可能触发多次刷新 + * - 太长(如 5000ms)→ 用户感知延迟过高 + */ +export const REFRESH_DEBOUNCE_DELAY = 2000; diff --git a/src/views/Community/index.js b/src/views/Community/index.js index e5b71631..351f2dd3 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -1,6 +1,6 @@ // src/views/Community/index.js import React, { useEffect, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux'; import { fetchPopularKeywords, @@ -40,6 +40,7 @@ import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme'; const Community = () => { const navigate = useNavigate(); + const location = useLocation(); // ⚡ 获取当前路由信息(用于判断是否在 /community 页面) const dispatch = useDispatch(); // Redux状态 @@ -71,7 +72,10 @@ const Community = () => { }); // ⚡ 通知权限引导 - const { browserPermission, requestBrowserPermission } = useNotification(); + const { browserPermission, requestBrowserPermission, registerEventUpdateCallback } = useNotification(); + + // ⚡ DynamicNewsCard 的 ref(用于触发刷新) + const dynamicNewsCardRef = useRef(null); // 通知横幅显示状态 const [showNotificationBanner, setShowNotificationBanner] = useState(false); @@ -160,6 +164,63 @@ const Community = () => { return () => clearTimeout(timer); }, []); // 空依赖数组,只在组件挂载时执行一次 + /** + * ⚡ 【核心逻辑】注册 Socket 新事件回调 - 当收到新事件时智能刷新列表 + * + * 工作流程: + * 1. Socket 收到 'new_event' 事件 → NotificationContext 触发所有注册的回调 + * 2. 本回调被触发 → 检查当前路由是否为 /community + * 3. 如果在 /community 页面 → 调用 DynamicNewsCard.refresh() 方法 + * 4. DynamicNewsCard 根据模式和滚动位置决定是否刷新: + * - 纵向模式 + 第1页 → 刷新列表 + * - 纵向模式 + 其他页 → 不刷新(避免打断用户) + * - 平铺模式 + 滚动在顶部 → 刷新列表 + * - 平铺模式 + 滚动不在顶部 → 仅显示 Toast 提示 + * + * 设计要点: + * - 使用 registerEventUpdateCallback 注册回调,返回的函数用于清理 + * - 路由检查:只在 /community 页面触发刷新 + * - 智能刷新:由 DynamicNewsCard 根据上下文决定刷新策略 + * - 自动清理:组件卸载时自动注销回调 + */ + useEffect(() => { + // 定义回调函数 + const handleNewEvent = (eventData) => { + console.log('[Community] 🔔 收到新事件通知', { + currentPath: location.pathname, + eventData, + }); + + // 检查是否在 /community 页面 + if (location.pathname === '/community') { + console.log('[Community] ✅ 当前在事件中心页面,触发 DynamicNewsCard 刷新'); + + // 调用 DynamicNewsCard 的 refresh 方法(智能刷新) + if (dynamicNewsCardRef.current) { + dynamicNewsCardRef.current.refresh(); + } else { + console.warn('[Community] ⚠️ DynamicNewsCard ref 不可用,无法触发刷新'); + } + } else { + console.log('[Community] ⏭️ 当前不在事件中心页面,跳过刷新', { + currentPath: location.pathname, + }); + } + }; + + // 注册回调(返回清理函数) + const unregister = registerEventUpdateCallback(handleNewEvent); + console.log('[Community] ✅ 已注册 Socket 事件更新回调'); + + // 组件卸载时清理 + return () => { + if (unregister) { + unregister(); + console.log('[Community] 🧹 已注销 Socket 事件更新回调'); + } + }; + }, [location.pathname, registerEventUpdateCallback]); // 依赖路由变化重新注册 + return ( {/* 主内容区域 */} @@ -206,6 +267,7 @@ const Community = () => { {/* 实时要闻·动态追踪 - 横向滚动 */}