From 429e96475f2856f5f0624902f797951de129af3e Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 8 Dec 2025 15:40:47 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=9B=BF=E6=8D=A2=E5=85=AC?= =?UTF-8?q?=E4=BC=97=E5=8F=B7=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/MP_verify_17Fo4JhapMw6vtNa.txt | 1 + public/MP_verify_k8W6K1RDHiU4U3nw.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 public/MP_verify_17Fo4JhapMw6vtNa.txt delete mode 100644 public/MP_verify_k8W6K1RDHiU4U3nw.txt diff --git a/public/MP_verify_17Fo4JhapMw6vtNa.txt b/public/MP_verify_17Fo4JhapMw6vtNa.txt new file mode 100644 index 00000000..6792be56 --- /dev/null +++ b/public/MP_verify_17Fo4JhapMw6vtNa.txt @@ -0,0 +1 @@ +17Fo4JhapMw6vtNa diff --git a/public/MP_verify_k8W6K1RDHiU4U3nw.txt b/public/MP_verify_k8W6K1RDHiU4U3nw.txt deleted file mode 100644 index 6bfb00f4..00000000 --- a/public/MP_verify_k8W6K1RDHiU4U3nw.txt +++ /dev/null @@ -1 +0,0 @@ -k8W6K1RDHiU4U3nw \ No newline at end of file From 4a5cd891bd9040f2de195c0f25e3f6e94e5f35bf Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 8 Dec 2025 17:30:33 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20React=20?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E8=BF=BD=E8=B8=AA=20Hooks=20(usePerformanceT?= =?UTF-8?q?racker)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePerformanceTracker: 自动追踪组件渲染性能(mount/rendered/unmount) - usePerformanceMark: 手动标记自定义操作的性能 - 基于 performanceMonitor.ts 封装,提供 React 友好的 API - 完整 TypeScript 类型支持 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/hooks/usePerformanceTracker.ts | 129 +++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/hooks/usePerformanceTracker.ts 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; From 915ac2ebd3d5e5084a37ea45f6f3a039d387d7dd Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 8 Dec 2025 19:03:14 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=AA=E8=82=A1?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E4=B8=8B=E6=8B=89=E5=BC=B9=E7=AA=97=E8=A2=AB?= =?UTF-8?q?=E9=81=AE=E6=8C=A1=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hero Section 的 overflow: hidden 会裁剪超出边界的搜索下拉框, 改为 overflow: visible 解决此问题。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/views/StockOverview/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/StockOverview/index.js b/src/views/StockOverview/index.js index 818093a3..6a6417e7 100644 --- a/src/views/StockOverview/index.js +++ b/src/views/StockOverview/index.js @@ -583,7 +583,7 @@ const StockOverview = () => { position="relative" bgGradient={heroBg} color="white" - overflow="hidden" + overflow="visible" pt={{ base: 20, md: 24 }} pb={{ base: 16, md: 20 }} borderBottom={colorMode === 'dark' ? `2px solid ${goldColor}` : 'none'} From 65f71603e18fb1d0256380e28840bfa466afdd8b Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 8 Dec 2025 19:23:12 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E6=96=87=E6=A1=88=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Company/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Company/index.js b/src/views/Company/index.js index 3b5feca4..cf5b3723 100644 --- a/src/views/Company/index.js +++ b/src/views/Company/index.js @@ -183,7 +183,7 @@ const CompanyIndex = () => { - 个股信息分析 + 个股详情 查看股票实时行情、财务数据和盈利预测 From a8c8fe4211e67cf7d386371d62570a8d5c423358 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 8 Dec 2025 19:33:26 +0800 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8=20performanc?= =?UTF-8?q?eMonitor=20=E6=9B=BF=E6=8D=A2=20useFirstScreenMetrics=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=20performance.now()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useFirstScreenMetrics: 用 performanceMonitor.mark/measure 替换手动时间计算 - useSkeletonTiming: 用 usePerformanceMark Hook 重构,支持自定义前缀 - 所有性能数据统一到 performanceMonitor,可通过 generateReport() 查看 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/hooks/useFirstScreenMetrics.ts | 69 +++++++++++++++++++----------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/src/hooks/useFirstScreenMetrics.ts b/src/hooks/useFirstScreenMetrics.ts index 58b1afc6..10dda43b 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(() => { @@ -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, From 641514bbfdeb9c7f575851226efa227f514b7dc4 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 8 Dec 2025 19:34:59 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20remeasure=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E6=95=B0=E7=BB=84=E7=BC=BA=E5=B0=91=20pageTy?= =?UTF-8?q?pe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/hooks/useFirstScreenMetrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useFirstScreenMetrics.ts b/src/hooks/useFirstScreenMetrics.ts index 10dda43b..018af60c 100644 --- a/src/hooks/useFirstScreenMetrics.ts +++ b/src/hooks/useFirstScreenMetrics.ts @@ -183,7 +183,7 @@ export const useFirstScreenMetrics = ( setIsLoading(false); } }, 1000); // 延迟 1 秒收集 - }, [collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]); + }, [pageType, collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]); /** * 导出指标为 JSON From 76f13d6098297ac49e508452c5af85dc2c6a42c6 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 8 Dec 2025 19:43:02 +0800 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=20Community=20?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=8B=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 PageNavigationButton.js(功能已整合到 PaginationControl) - 删除 useInfiniteScroll.js(已被 @tanstack/react-virtual 替代) - 删除 EventDiscussionModal.js(已废弃) - 删除 PopularKeywords.js(功能已整合到其他组件) - 移除 index.js 中未使用的 useColorModeValue 导入 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../DynamicNewsCard/PageNavigationButton.js | 83 --- .../hooks/useInfiniteScroll.js | 88 --- .../components/EventDiscussionModal.js | 614 ------------------ .../Community/components/PopularKeywords.js | 202 ------ src/views/Community/index.js | 1 - 5 files changed, 988 deletions(-) delete mode 100644 src/views/Community/components/DynamicNewsCard/PageNavigationButton.js delete mode 100644 src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js delete mode 100644 src/views/Community/components/EventDiscussionModal.js delete mode 100644 src/views/Community/components/PopularKeywords.js 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} - /> -