diff --git a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js b/src/components/Charts/Stock/MiniTimelineChart.js similarity index 99% rename from src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js rename to src/components/Charts/Stock/MiniTimelineChart.js index 83eb843c..e839fed7 100644 --- a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js +++ b/src/components/Charts/Stock/MiniTimelineChart.js @@ -1,4 +1,4 @@ -// src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js +// src/components/Charts/Stock/MiniTimelineChart.js import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import ReactECharts from 'echarts-for-react'; import * as echarts from 'echarts'; diff --git a/src/components/Charts/Stock/hooks/index.js b/src/components/Charts/Stock/hooks/index.js new file mode 100644 index 00000000..9a90a094 --- /dev/null +++ b/src/components/Charts/Stock/hooks/index.js @@ -0,0 +1,4 @@ +// src/components/Charts/Stock/hooks/index.js +// 股票图表 Hooks 统一导出 + +export { useEventStocks } from './useEventStocks'; diff --git a/src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js b/src/components/Charts/Stock/hooks/useEventStocks.js similarity index 97% rename from src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js rename to src/components/Charts/Stock/hooks/useEventStocks.js index 0e7867ae..d3420e23 100644 --- a/src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js +++ b/src/components/Charts/Stock/hooks/useEventStocks.js @@ -1,4 +1,4 @@ -// src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js +// src/components/Charts/Stock/hooks/useEventStocks.js import { useSelector, useDispatch, shallowEqual } from 'react-redux'; import { useEffect, useCallback, useMemo } from 'react'; import { @@ -8,8 +8,8 @@ import { fetchHistoricalEvents, fetchChainAnalysis, fetchExpectationScore -} from '../../../../../store/slices/stockSlice'; -import { logger } from '../../../../../utils/logger'; +} from '@store/slices/stockSlice'; +import { logger } from '@utils/logger'; /** * 事件股票数据 Hook diff --git a/src/components/Charts/Stock/index.js b/src/components/Charts/Stock/index.js new file mode 100644 index 00000000..c714e907 --- /dev/null +++ b/src/components/Charts/Stock/index.js @@ -0,0 +1,5 @@ +// src/components/Charts/Stock/index.js +// 股票图表组件统一导出 + +export { default as MiniTimelineChart } from './MiniTimelineChart'; +export { useEventStocks } from './hooks/useEventStocks'; diff --git a/src/components/EventDetailPanel/DynamicNewsDetailPanel.js b/src/components/EventDetailPanel/DynamicNewsDetailPanel.js index f4100416..d6ceb26a 100644 --- a/src/components/EventDetailPanel/DynamicNewsDetailPanel.js +++ b/src/components/EventDetailPanel/DynamicNewsDetailPanel.js @@ -16,7 +16,7 @@ import { } from '@chakra-ui/react'; import { getImportanceConfig } from '@constants/importanceLevels'; import { eventService } from '@services/eventService'; -import { useEventStocks } from '@views/Community/components/StockDetailPanel/hooks/useEventStocks'; +import { useEventStocks } from '@components/Charts/Stock'; import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice'; import { useAuth } from '@contexts/AuthContext'; import EventHeaderInfo from './EventHeaderInfo'; diff --git a/src/components/EventDetailPanel/StockListItem.js b/src/components/EventDetailPanel/StockListItem.js index 9ac9bb25..fedf176c 100644 --- a/src/components/EventDetailPanel/StockListItem.js +++ b/src/components/EventDetailPanel/StockListItem.js @@ -20,7 +20,7 @@ import { StarIcon } from '@chakra-ui/icons'; import { Tag } from 'antd'; import { RobotOutlined } from '@ant-design/icons'; import { selectIsMobile } from '@store/slices/deviceSlice'; -import MiniTimelineChart from '@views/Community/components/StockDetailPanel/components/MiniTimelineChart'; +import { MiniTimelineChart } from '@components/Charts/Stock'; import MiniKLineChart from './MiniKLineChart'; import TimelineChartModal from '@components/StockChart/TimelineChartModal'; import KLineChartModal from '@components/StockChart/KLineChartModal'; diff --git a/src/hooks/useFirstScreenMetrics.ts b/src/hooks/useFirstScreenMetrics.ts index 58b1afc6..018af60c 100644 --- a/src/hooks/useFirstScreenMetrics.ts +++ b/src/hooks/useFirstScreenMetrics.ts @@ -16,6 +16,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { initWebVitalsTracking, getCachedMetrics } from '@utils/performance/webVitals'; import { collectResourceStats, collectApiStats } from '@utils/performance/resourceMonitor'; +import { performanceMonitor } from '@utils/performanceMonitor'; +import { usePerformanceMark } from '@hooks/usePerformanceTracker'; import posthog from 'posthog-js'; import type { FirstScreenMetrics, @@ -44,11 +46,17 @@ export const useFirstScreenMetrics = ( const [isLoading, setIsLoading] = useState(true); const [metrics, setMetrics] = useState(null); - // 使用 ref 记录页面加载开始时间 - const pageLoadStartRef = useRef(performance.now()); - const skeletonStartRef = useRef(performance.now()); + // 使用 ref 避免重复标记 + const hasMarkedRef = useRef(false); const hasInitializedRef = useRef(false); + // 在组件首次渲染时标记开始时间点 + if (!hasMarkedRef.current) { + hasMarkedRef.current = true; + performanceMonitor.mark(`${pageType}-page-load-start`); + performanceMonitor.mark(`${pageType}-skeleton-start`); + } + /** * 收集所有首屏指标 */ @@ -82,12 +90,20 @@ export const useFirstScreenMetrics = ( customProperties, }); - // 5. 计算首屏可交互时间(TTI) - const now = performance.now(); - const timeToInteractive = now - pageLoadStartRef.current; + // 5. 标记可交互时间点,并计算 TTI + performanceMonitor.mark(`${pageType}-interactive`); + const timeToInteractive = performanceMonitor.measure( + `${pageType}-page-load-start`, + `${pageType}-interactive`, + `${pageType} TTI` + ) || 0; // 6. 计算骨架屏展示时长 - const skeletonDisplayDuration = now - skeletonStartRef.current; + const skeletonDisplayDuration = performanceMonitor.measure( + `${pageType}-skeleton-start`, + `${pageType}-interactive`, + `${pageType} 骨架屏时长` + ) || 0; const firstScreenMetrics: FirstScreenMetrics = { webVitals, @@ -143,9 +159,9 @@ export const useFirstScreenMetrics = ( const remeasure = useCallback(() => { setIsLoading(true); - // 重置计时器 - pageLoadStartRef.current = performance.now(); - skeletonStartRef.current = performance.now(); + // 重置性能标记 + performanceMonitor.mark(`${pageType}-page-load-start`); + performanceMonitor.mark(`${pageType}-skeleton-start`); // 延迟收集指标(等待 Web Vitals 完成) setTimeout(() => { @@ -167,7 +183,7 @@ export const useFirstScreenMetrics = ( setIsLoading(false); } }, 1000); // 延迟 1 秒收集 - }, [collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]); + }, [pageType, collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]); /** * 导出指标为 JSON @@ -247,7 +263,7 @@ export const useFirstScreenMetrics = ( * * 使用示例: * ```tsx - * const { markSkeletonEnd } = useSkeletonTiming(); + * const { markSkeletonEnd } = useSkeletonTiming('home-skeleton'); * * useEffect(() => { * if (!loading) { @@ -256,27 +272,32 @@ export const useFirstScreenMetrics = ( * }, [loading, markSkeletonEnd]); * ``` */ -export const useSkeletonTiming = () => { - const skeletonStartRef = useRef(performance.now()); - const skeletonEndRef = useRef(null); +export const useSkeletonTiming = (prefix = 'skeleton') => { + const { mark, getMeasure } = usePerformanceMark(prefix); + const hasMarkedEndRef = useRef(false); + const hasMarkedStartRef = useRef(false); + + // 在组件首次渲染时标记开始 + if (!hasMarkedStartRef.current) { + hasMarkedStartRef.current = true; + mark('start'); + } const markSkeletonEnd = useCallback(() => { - if (!skeletonEndRef.current) { - skeletonEndRef.current = performance.now(); - const duration = skeletonEndRef.current - skeletonStartRef.current; + if (!hasMarkedEndRef.current) { + hasMarkedEndRef.current = true; + mark('end'); + const duration = getMeasure('start', 'end'); - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === 'development' && duration) { console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`); } } - }, []); + }, [mark, getMeasure]); const getSkeletonDuration = useCallback((): number | null => { - if (skeletonEndRef.current) { - return skeletonEndRef.current - skeletonStartRef.current; - } - return null; - }, []); + return getMeasure('start', 'end'); + }, [getMeasure]); return { markSkeletonEnd, diff --git a/src/hooks/usePerformanceTracker.ts b/src/hooks/usePerformanceTracker.ts new file mode 100644 index 00000000..6d2fcf79 --- /dev/null +++ b/src/hooks/usePerformanceTracker.ts @@ -0,0 +1,129 @@ +/** + * React 性能追踪 Hooks + * 封装 performanceMonitor 工具,提供 React 友好的性能追踪 API + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { performanceMonitor } from '@utils/performanceMonitor'; + +/** + * usePerformanceMark 返回值类型 + */ +export interface UsePerformanceMarkReturn { + /** 标记时间点 */ + mark: (suffix: string) => void; + /** 测量并记录到 performanceMonitor */ + measure: (startSuffix: string, endSuffix: string, name?: string) => number | null; + /** 获取测量值(不记录) */ + getMeasure: (startSuffix: string, endSuffix: string) => number | null; +} + +/** + * usePerformanceTracker - 自动追踪组件渲染性能 + * + * @param componentName - 组件名称,用于标记 + * @param options - 配置选项 + * + * @example + * ```tsx + * function MyComponent() { + * usePerformanceTracker('MyComponent'); + * return
...
; + * } + * ``` + * + * 自动标记: + * - {componentName}-mount: 组件挂载时 + * - {componentName}-rendered: 首次渲染完成 + * - {componentName}-unmount: 组件卸载时 + */ +export function usePerformanceTracker( + componentName: string, + options: { trackRender?: boolean } = {} +): void { + const { trackRender = true } = options; + const hasMounted = useRef(false); + + // 首次渲染时立即标记(同步) + if (!hasMounted.current) { + performanceMonitor.mark(`${componentName}-mount`); + } + + useEffect(() => { + if (!hasMounted.current) { + hasMounted.current = true; + + // 渲染完成标记(在 useEffect 中,表示 DOM 已更新) + if (trackRender) { + performanceMonitor.mark(`${componentName}-rendered`); + performanceMonitor.measure( + `${componentName}-mount`, + `${componentName}-rendered`, + `${componentName} 渲染` + ); + } + } + + // 组件卸载时标记 + return () => { + performanceMonitor.mark(`${componentName}-unmount`); + }; + }, [componentName, trackRender]); +} + +/** + * usePerformanceMark - 手动标记自定义操作的性能 + * + * @param prefix - 标记前缀,用于区分不同操作 + * @returns 包含 mark、measure、getMeasure 方法的对象 + * + * @example + * ```tsx + * function MyComponent() { + * const { mark, getMeasure } = usePerformanceMark('api-call'); + * + * const handleFetch = async () => { + * mark('start'); + * await fetchData(); + * mark('end'); + * const duration = getMeasure('start', 'end'); + * console.log('API耗时:', duration, 'ms'); + * }; + * + * return ; + * } + * ``` + */ +export function usePerformanceMark(prefix: string): UsePerformanceMarkReturn { + const mark = useCallback( + (suffix: string) => { + performanceMonitor.mark(`${prefix}-${suffix}`); + }, + [prefix] + ); + + const measure = useCallback( + (startSuffix: string, endSuffix: string, name?: string) => { + return performanceMonitor.measure( + `${prefix}-${startSuffix}`, + `${prefix}-${endSuffix}`, + name || `${prefix}: ${startSuffix} → ${endSuffix}` + ); + }, + [prefix] + ); + + const getMeasure = useCallback( + (startSuffix: string, endSuffix: string) => { + return performanceMonitor.measure( + `${prefix}-${startSuffix}`, + `${prefix}-${endSuffix}` + ); + }, + [prefix] + ); + + return { mark, measure, getMeasure }; +} + +export default usePerformanceTracker; diff --git a/src/hooks/useSearchEvents.js b/src/hooks/useSearchEvents.js deleted file mode 100644 index bd120042..00000000 --- a/src/hooks/useSearchEvents.js +++ /dev/null @@ -1,244 +0,0 @@ -// src/hooks/useSearchEvents.js -// 全局搜索功能事件追踪 Hook - -import { useCallback } from 'react'; -import { usePostHogTrack } from './usePostHogRedux'; -import { RETENTION_EVENTS } from '../lib/constants'; -import { logger } from '../utils/logger'; - -/** - * 全局搜索事件追踪 Hook - * @param {Object} options - 配置选项 - * @param {string} options.context - 搜索上下文 ('global' | 'stock' | 'news' | 'concept' | 'simulation') - * @returns {Object} 事件追踪处理函数集合 - */ -export const useSearchEvents = ({ context = 'global' } = {}) => { - const { track } = usePostHogTrack(); - - /** - * 追踪搜索开始(聚焦搜索框) - * @param {string} placeholder - 搜索框提示文本 - */ - const trackSearchInitiated = useCallback((placeholder = '') => { - track(RETENTION_EVENTS.SEARCH_INITIATED, { - context, - placeholder, - timestamp: new Date().toISOString(), - }); - - logger.debug('useSearchEvents', '🔍 Search Initiated', { - context, - placeholder, - }); - }, [track, context]); - - /** - * 追踪搜索查询提交 - * @param {string} query - 搜索查询词 - * @param {number} resultCount - 搜索结果数量 - * @param {Object} filters - 应用的筛选条件 - */ - const trackSearchQuerySubmitted = useCallback((query, resultCount = 0, filters = {}) => { - if (!query) { - logger.warn('useSearchEvents', 'trackSearchQuerySubmitted: query is required'); - return; - } - - track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, { - query, - query_length: query.length, - result_count: resultCount, - has_results: resultCount > 0, - context, - filters: filters, - filter_count: Object.keys(filters).length, - timestamp: new Date().toISOString(), - }); - - // 如果没有搜索结果,额外追踪 - if (resultCount === 0) { - track(RETENTION_EVENTS.SEARCH_NO_RESULTS, { - query, - context, - filters, - timestamp: new Date().toISOString(), - }); - - logger.debug('useSearchEvents', '❌ Search No Results', { - query, - context, - }); - } else { - logger.debug('useSearchEvents', '✅ Search Query Submitted', { - query, - resultCount, - context, - }); - } - }, [track, context]); - - /** - * 追踪搜索结果点击 - * @param {Object} result - 被点击的搜索结果 - * @param {string} result.type - 结果类型 ('stock' | 'news' | 'concept' | 'event') - * @param {string} result.id - 结果ID - * @param {string} result.title - 结果标题 - * @param {number} position - 在搜索结果中的位置 - * @param {string} query - 搜索查询词 - */ - const trackSearchResultClicked = useCallback((result, position = 0, query = '') => { - if (!result || !result.type) { - logger.warn('useSearchEvents', 'trackSearchResultClicked: result object with type is required'); - return; - } - - track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, { - result_type: result.type, - result_id: result.id || result.code || '', - result_title: result.title || result.name || '', - position, - query, - context, - timestamp: new Date().toISOString(), - }); - - logger.debug('useSearchEvents', '🎯 Search Result Clicked', { - type: result.type, - id: result.id || result.code, - position, - context, - }); - }, [track, context]); - - /** - * 追踪搜索筛选应用 - * @param {Object} filters - 应用的筛选条件 - * @param {string} filterType - 筛选类型 ('sort' | 'category' | 'date_range' | 'price_range') - * @param {any} filterValue - 筛选值 - */ - const trackSearchFilterApplied = useCallback((filterType, filterValue, filters = {}) => { - if (!filterType) { - logger.warn('useSearchEvents', 'trackSearchFilterApplied: filterType is required'); - return; - } - - track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, { - filter_type: filterType, - filter_value: String(filterValue), - all_filters: filters, - context, - timestamp: new Date().toISOString(), - }); - - logger.debug('useSearchEvents', '🔍 Search Filter Applied', { - filterType, - filterValue, - context, - }); - }, [track, context]); - - /** - * 追踪搜索建议点击(自动完成) - * @param {string} suggestion - 被点击的搜索建议 - * @param {number} position - 在建议列表中的位置 - * @param {string} source - 建议来源 ('history' | 'popular' | 'related') - */ - const trackSearchSuggestionClicked = useCallback((suggestion, position = 0, source = 'popular') => { - if (!suggestion) { - logger.warn('useSearchEvents', 'trackSearchSuggestionClicked: suggestion is required'); - return; - } - - track('Search Suggestion Clicked', { - suggestion, - position, - source, - context, - timestamp: new Date().toISOString(), - }); - - logger.debug('useSearchEvents', '💡 Search Suggestion Clicked', { - suggestion, - position, - source, - context, - }); - }, [track, context]); - - /** - * 追踪搜索历史查看 - * @param {number} historyCount - 历史记录数量 - */ - const trackSearchHistoryViewed = useCallback((historyCount = 0) => { - track('Search History Viewed', { - history_count: historyCount, - has_history: historyCount > 0, - context, - timestamp: new Date().toISOString(), - }); - - logger.debug('useSearchEvents', '📜 Search History Viewed', { - historyCount, - context, - }); - }, [track, context]); - - /** - * 追踪搜索历史清除 - */ - const trackSearchHistoryCleared = useCallback(() => { - track('Search History Cleared', { - context, - timestamp: new Date().toISOString(), - }); - - logger.debug('useSearchEvents', '🗑️ Search History Cleared', { - context, - }); - }, [track, context]); - - /** - * 追踪热门搜索词点击 - * @param {string} keyword - 被点击的热门关键词 - * @param {number} position - 在列表中的位置 - * @param {number} heatScore - 热度分数 - */ - const trackPopularKeywordClicked = useCallback((keyword, position = 0, heatScore = 0) => { - if (!keyword) { - logger.warn('useSearchEvents', 'trackPopularKeywordClicked: keyword is required'); - return; - } - - track('Popular Keyword Clicked', { - keyword, - position, - heat_score: heatScore, - context, - timestamp: new Date().toISOString(), - }); - - logger.debug('useSearchEvents', '🔥 Popular Keyword Clicked', { - keyword, - position, - context, - }); - }, [track, context]); - - return { - // 搜索流程事件 - trackSearchInitiated, - trackSearchQuerySubmitted, - trackSearchResultClicked, - - // 筛选和建议 - trackSearchFilterApplied, - trackSearchSuggestionClicked, - - // 历史和热门 - trackSearchHistoryViewed, - trackSearchHistoryCleared, - trackPopularKeywordClicked, - }; -}; - -export default useSearchEvents; diff --git a/src/views/Community/components/DynamicNewsCard/PageNavigationButton.js b/src/views/Community/components/DynamicNewsCard/PageNavigationButton.js deleted file mode 100644 index 723e89cd..00000000 --- a/src/views/Community/components/DynamicNewsCard/PageNavigationButton.js +++ /dev/null @@ -1,83 +0,0 @@ -// src/views/Community/components/DynamicNewsCard/PageNavigationButton.js -// 翻页导航按钮组件 - -import React from 'react'; -import { IconButton, useColorModeValue } from '@chakra-ui/react'; -import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'; - -/** - * 翻页导航按钮组件 - * @param {Object} props - * @param {'prev'|'next'} props.direction - 按钮方向(prev=上一页,next=下一页) - * @param {number} props.currentPage - 当前页码 - * @param {number} props.totalPages - 总页数 - * @param {Function} props.onPageChange - 翻页回调 - * @param {string} props.mode - 显示模式(只在carousel/grid模式下显示) - */ -const PageNavigationButton = ({ - direction, - currentPage, - totalPages, - onPageChange, - mode -}) => { - // 主题适配 - const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)'); - const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'); - - // 根据方向计算配置 - const isPrev = direction === 'prev'; - const isNext = direction === 'next'; - - const Icon = isPrev ? ChevronLeftIcon : ChevronRightIcon; - const position = isPrev ? 'left' : 'right'; - const label = isPrev ? '上一页' : '下一页'; - const targetPage = isPrev ? currentPage - 1 : currentPage + 1; - const shouldShow = isPrev - ? currentPage > 1 - : currentPage < totalPages; - const isDisabled = isNext ? currentPage >= totalPages : false; - - // 判断是否显示(只在单排/双排模式显示) - const shouldRender = shouldShow && (mode === 'carousel' || mode === 'grid'); - - if (!shouldRender) return null; - - const handleClick = () => { - console.log( - `%c🔵 [翻页] 点击${label}: 当前页${currentPage} → 目标页${targetPage} (共${totalPages}页)`, - 'color: #3B82F6; font-weight: bold;' - ); - onPageChange(targetPage); - }; - - return ( - } - position="absolute" - {...{ [position]: 0 }} - top="50%" - transform="translateY(-50%)" - zIndex={2} - onClick={handleClick} - variant="ghost" - size="md" - w="40px" - h="40px" - minW="40px" - borderRadius="full" - bg={arrowBtnBg} - boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)" - _hover={{ - bg: arrowBtnHoverBg, - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)', - transform: 'translateY(-50%) scale(1.05)' - }} - isDisabled={isDisabled} - aria-label={label} - title={label} - /> - ); -}; - -export default PageNavigationButton; diff --git a/src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js b/src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js deleted file mode 100644 index 17ccfb8c..00000000 --- a/src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js +++ /dev/null @@ -1,88 +0,0 @@ -// 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 }; -}; diff --git a/src/views/Community/components/EventDiscussionModal.js b/src/views/Community/components/EventDiscussionModal.js deleted file mode 100644 index c5731937..00000000 --- a/src/views/Community/components/EventDiscussionModal.js +++ /dev/null @@ -1,614 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - Box, - Text, - VStack, - HStack, - Avatar, - Textarea, - Button, - Divider, - useToast, - Badge, - Flex, - IconButton, - Menu, - MenuButton, - MenuList, - MenuItem, - useColorModeValue, - Spinner, - Center, - Collapse, - Input, -} from '@chakra-ui/react'; -import { - ChatIcon, - TimeIcon, - DeleteIcon, - EditIcon, - ChevronDownIcon, - TriangleDownIcon, - TriangleUpIcon, -} from '@chakra-ui/icons'; -import { FaHeart, FaRegHeart, FaComment } from 'react-icons/fa'; -import { format } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; -import { eventService } from '../../../services/eventService'; -import { logger } from '../../../utils/logger'; - -const EventDiscussionModal = ({ isOpen, onClose, eventId, eventTitle, discussionType = '事件讨论' }) => { - const [posts, setPosts] = useState([]); - const [newPostContent, setNewPostContent] = useState(''); - const [newPostTitle, setNewPostTitle] = useState(''); - const [loading, setLoading] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [expandedPosts, setExpandedPosts] = useState({}); - const [postComments, setPostComments] = useState({}); - const [replyContents, setReplyContents] = useState({}); - const [loadingComments, setLoadingComments] = useState({}); - - const toast = useToast(); - const bgColor = useColorModeValue('white', 'gray.800'); - const borderColor = useColorModeValue('gray.200', 'gray.600'); - const hoverBg = useColorModeValue('gray.50', 'gray.700'); - - // 加载帖子列表 - const loadPosts = async () => { - if (!eventId) return; - - setLoading(true); - try { - const response = await fetch(`/api/events/${eventId}/posts?sort=latest&page=1&per_page=20`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include' - }); - const result = await response.json(); - - if (response.ok && result.success) { - setPosts(result.data || []); - logger.debug('EventDiscussionModal', '帖子列表加载成功', { - eventId, - postsCount: result.data?.length || 0 - }); - } else { - logger.error('EventDiscussionModal', 'loadPosts', new Error('API返回错误'), { - eventId, - status: response.status, - message: result.message - }); - toast({ - title: '加载帖子失败', - status: 'error', - duration: 3000, - isClosable: true, - }); - } - } catch (error) { - logger.error('EventDiscussionModal', 'loadPosts', error, { eventId }); - toast({ - title: '加载帖子失败', - status: 'error', - duration: 3000, - isClosable: true, - }); - } finally { - setLoading(false); - } - }; - - // 加载帖子的评论 - const loadPostComments = async (postId) => { - setLoadingComments(prev => ({ ...prev, [postId]: true })); - try { - const response = await fetch(`/api/posts/${postId}/comments?sort=latest`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include' - }); - const result = await response.json(); - - if (response.ok && result.success) { - setPostComments(prev => ({ ...prev, [postId]: result.data || [] })); - logger.debug('EventDiscussionModal', '评论加载成功', { - postId, - commentsCount: result.data?.length || 0 - }); - } - } catch (error) { - logger.error('EventDiscussionModal', 'loadPostComments', error, { postId }); - } finally { - setLoadingComments(prev => ({ ...prev, [postId]: false })); - } - }; - - // 切换展开/收起评论 - const togglePostComments = async (postId) => { - const isExpanded = expandedPosts[postId]; - if (!isExpanded) { - // 展开时加载评论 - await loadPostComments(postId); - } - setExpandedPosts(prev => ({ ...prev, [postId]: !isExpanded })); - }; - - // 提交新帖子 - const handleSubmitPost = async () => { - if (!newPostContent.trim()) return; - - setSubmitting(true); - try { - const response = await fetch(`/api/events/${eventId}/posts`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - title: newPostTitle.trim(), - content: newPostContent.trim(), - content_type: 'text', - }) - }); - const result = await response.json(); - - if (response.ok && result.success) { - setNewPostContent(''); - setNewPostTitle(''); - loadPosts(); - logger.info('EventDiscussionModal', '帖子发布成功', { - eventId, - postId: result.data?.id - }); - toast({ - title: '帖子发布成功', - status: 'success', - duration: 2000, - isClosable: true, - }); - } else { - logger.error('EventDiscussionModal', 'handleSubmitPost', new Error('API返回错误'), { - eventId, - message: result.message - }); - toast({ - title: result.message || '帖子发布失败', - status: 'error', - duration: 3000, - isClosable: true, - }); - } - } catch (error) { - logger.error('EventDiscussionModal', 'handleSubmitPost', error, { eventId }); - toast({ - title: '帖子发布失败', - status: 'error', - duration: 3000, - isClosable: true, - }); - } finally { - setSubmitting(false); - } - }; - - // 删除帖子 - const handleDeletePost = async (postId) => { - if (!window.confirm('确定要删除这个帖子吗?')) return; - - try { - const response = await fetch(`/api/posts/${postId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include' - }); - const result = await response.json(); - - if (response.ok && result.success) { - loadPosts(); - logger.info('EventDiscussionModal', '帖子删除成功', { postId }); - toast({ - title: '帖子已删除', - status: 'success', - duration: 2000, - isClosable: true, - }); - } else { - logger.error('EventDiscussionModal', 'handleDeletePost', new Error('API返回错误'), { - postId, - message: result.message - }); - toast({ - title: result.message || '删除失败', - status: 'error', - duration: 3000, - isClosable: true, - }); - } - } catch (error) { - logger.error('EventDiscussionModal', 'handleDeletePost', error, { postId }); - toast({ - title: '删除失败', - status: 'error', - duration: 3000, - isClosable: true, - }); - } - }; - - // 点赞帖子 - const handleLikePost = async (postId) => { - try { - const response = await fetch(`/api/posts/${postId}/like`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include' - }); - const result = await response.json(); - - if (response.ok && result.success) { - // 更新帖子列表中的点赞状态 - setPosts(prev => prev.map(post => - post.id === postId - ? { ...post, likes_count: result.likes_count, liked: result.liked } - : post - )); - logger.debug('EventDiscussionModal', '点赞操作成功', { - postId, - liked: result.liked, - likesCount: result.likes_count - }); - } - } catch (error) { - logger.error('EventDiscussionModal', 'handleLikePost', error, { postId }); - toast({ - title: '操作失败', - status: 'error', - duration: 2000, - isClosable: true, - }); - } - }; - - // 提交评论 - const handleSubmitComment = async (postId) => { - const content = replyContents[postId]; - if (!content?.trim()) return; - - try { - const response = await fetch(`/api/posts/${postId}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - content: content.trim(), - }) - }); - const result = await response.json(); - - if (response.ok && result.success) { - setReplyContents(prev => ({ ...prev, [postId]: '' })); - // 重新加载该帖子的评论 - await loadPostComments(postId); - // 更新帖子的评论数 - setPosts(prev => prev.map(post => - post.id === postId - ? { ...post, comments_count: (post.comments_count || 0) + 1 } - : post - )); - logger.info('EventDiscussionModal', '评论发布成功', { - postId, - commentId: result.data?.id - }); - toast({ - title: '评论发布成功', - status: 'success', - duration: 2000, - isClosable: true, - }); - } - } catch (error) { - logger.error('EventDiscussionModal', 'handleSubmitComment', error, { postId }); - toast({ - title: '评论发布失败', - status: 'error', - duration: 3000, - isClosable: true, - }); - } - }; - - // 删除评论 - const handleDeleteComment = async (commentId, postId) => { - if (!window.confirm('确定要删除这条评论吗?')) return; - - try { - const response = await fetch(`/api/comments/${commentId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include' - }); - const result = await response.json(); - - if (response.ok && result.success) { - // 重新加载该帖子的评论 - await loadPostComments(postId); - // 更新帖子的评论数 - setPosts(prev => prev.map(post => - post.id === postId - ? { ...post, comments_count: Math.max(0, (post.comments_count || 0) - 1) } - : post - )); - logger.info('EventDiscussionModal', '评论删除成功', { commentId, postId }); - toast({ - title: '评论已删除', - status: 'success', - duration: 2000, - isClosable: true, - }); - } - } catch (error) { - logger.error('EventDiscussionModal', 'handleDeleteComment', error, { commentId, postId }); - toast({ - title: '删除失败', - status: 'error', - duration: 3000, - isClosable: true, - }); - } - }; - - useEffect(() => { - if (isOpen) { - loadPosts(); - } - }, [isOpen, eventId]); - - return ( - - - - - - - - {discussionType} - - {eventTitle && ( - - {eventTitle} - - )} - - - - - - {/* 发布新帖子 */} - - setNewPostTitle(e.target.value)} - placeholder="帖子标题(可选)" - size="sm" - mb={2} - /> -