From 796c623197372cbc6309f4117ff214cb3e471368 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 13:55:06 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix=EF=BC=9A=E4=BC=98=E5=8C=96h5/?= =?UTF-8?q?=E8=8F=9C=E5=8D=95UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Navbars/HomeNavbar.js | 24 ++++++------- .../Navbars/components/NavbarActions/index.js | 35 +++++++++++++++++-- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index 4af73df8..b64990e7 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -167,20 +167,14 @@ export default function HomeNavbar() { {/* 中间导航区域 - 响应式 (Phase 4 优化) */} - {isMobile ? ( - // 移动端:汉堡菜单 - } - variant="ghost" - onClick={onOpen} - aria-label="Open menu" - /> - ) : isTablet ? ( - // 中屏(平板):"更多"下拉菜单 - - ) : ( - // 大屏(桌面):完整导航菜单 - + {!isMobile && ( + isTablet ? ( + // 中屏(平板):"更多"下拉菜单 + + ) : ( + // 大屏(桌面):完整导航菜单 + + ) )} {/* 右侧功能区 (Phase 7 优化) */} @@ -189,6 +183,8 @@ export default function HomeNavbar() { isAuthenticated={isAuthenticated} user={user} isDesktop={isDesktop} + isMobile={isMobile} + onMenuOpen={onOpen} handleLogout={handleLogout} watchlistQuotes={watchlistQuotes} followingEvents={followingEvents} diff --git a/src/components/Navbars/components/NavbarActions/index.js b/src/components/Navbars/components/NavbarActions/index.js index 8610708f..7ca5b3c0 100644 --- a/src/components/Navbars/components/NavbarActions/index.js +++ b/src/components/Navbars/components/NavbarActions/index.js @@ -2,7 +2,8 @@ // Navbar 右侧功能区组件 import React, { memo } from 'react'; -import { HStack, Spinner } from '@chakra-ui/react'; +import { HStack, Spinner, IconButton } from '@chakra-ui/react'; +import { HamburgerIcon } from '@chakra-ui/icons'; import ThemeToggleButton from '../ThemeToggleButton'; import LoginButton from '../LoginButton'; import CalendarButton from '../CalendarButton'; @@ -19,6 +20,8 @@ import { PersonalCenterMenu } from '../Navigation'; * @param {boolean} props.isAuthenticated - 是否已登录 * @param {Object} props.user - 用户对象 * @param {boolean} props.isDesktop - 是否为桌面端 + * @param {boolean} props.isMobile - 是否为移动端 + * @param {Function} props.onMenuOpen - 打开移动端菜单的回调 * @param {Function} props.handleLogout - 登出回调 * @param {Array} props.watchlistQuotes - 自选股数据(用于 TabletUserMenu) * @param {Array} props.followingEvents - 关注事件数据(用于 TabletUserMenu) @@ -28,6 +31,8 @@ const NavbarActions = memo(({ isAuthenticated, user, isDesktop, + isMobile, + onMenuOpen, handleLogout, watchlistQuotes, followingEvents @@ -64,14 +69,38 @@ const NavbarActions = memo(({ /> )} + {/* 移动端汉堡菜单 - 在头像右侧 */} + {isMobile && ( + } + variant="ghost" + onClick={onMenuOpen} + aria-label="打开菜单" + size="md" + /> + )} + {/* 个人中心下拉菜单 - 仅大屏显示 */} {isDesktop && ( )} ) : ( - // 未登录状态 - 单一按钮 - + // 未登录状态 - 登录按钮 + 汉堡菜单 + + + + {/* 移动端汉堡菜单 - 未登录时也显示 */} + {isMobile && ( + } + variant="ghost" + onClick={onMenuOpen} + aria-label="打开菜单" + size="md" + /> + )} + )} ); From e8b3d13c0aa6001a8fcc78506c178507a693a8b9 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 14:07:04 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E5=88=A4=E6=96=AD=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Navbars/HomeNavbar.js | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index b64990e7..05f07933 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -44,7 +44,7 @@ import { useSubscription } from '../../hooks/useSubscription'; import { DesktopUserMenu, TabletUserMenu } from './components/UserMenu'; // Phase 4 优化: 提取的导航菜单组件 -import { DesktopNav, MoreMenu, PersonalCenterMenu } from './components/Navigation'; +import { DesktopNav, PersonalCenterMenu } from './components/Navigation'; // Phase 5 优化: 提取的移动端抽屉菜单组件 import { MobileDrawer } from './components/MobileDrawer'; @@ -67,7 +67,6 @@ export default function HomeNavbar() { const { isOpen, onOpen, onClose } = useDisclosure(); const navigate = useNavigate(); const isMobile = useBreakpointValue({ base: true, md: false }); - const isTablet = useBreakpointValue({ base: false, md: true, lg: false }); const isDesktop = useBreakpointValue({ base: false, md: false, lg: true }); const { user, isAuthenticated, logout, isLoading } = useAuth(); const { openAuthModal } = useAuthModal(); @@ -167,14 +166,9 @@ export default function HomeNavbar() { {/* 中间导航区域 - 响应式 (Phase 4 优化) */} - {!isMobile && ( - isTablet ? ( - // 中屏(平板):"更多"下拉菜单 - - ) : ( - // 大屏(桌面):完整导航菜单 - - ) + {isDesktop && ( + // 桌面端:完整导航菜单(移动端和平板端的汉堡菜单已移至右侧) + )} {/* 右侧功能区 (Phase 7 优化) */} @@ -183,7 +177,6 @@ export default function HomeNavbar() { isAuthenticated={isAuthenticated} user={user} isDesktop={isDesktop} - isMobile={isMobile} onMenuOpen={onOpen} handleLogout={handleLogout} watchlistQuotes={watchlistQuotes} From 834067f6792cc3ec7114dcdb82d43d2ccc9373d3 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 14:38:09 +0800 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=20GlobalCompone?= =?UTF-8?q?nts.js=EF=BC=88=E7=BC=93=E5=AD=98=20config=EF=BC=89=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=97=B6=E4=B8=8D=E4=BC=9A=E8=A7=A6=E5=8F=91=20Bytede?= =?UTF-8?q?skWidget=20=E9=87=8D=E6=96=B0=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/GlobalComponents.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/GlobalComponents.js b/src/components/GlobalComponents.js index 8ca4e954..59b4afd8 100644 --- a/src/components/GlobalComponents.js +++ b/src/components/GlobalComponents.js @@ -1,7 +1,7 @@ // src/components/GlobalComponents.js // 集中管理应用的全局组件 -import React from 'react'; +import React, { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useNotification } from '../contexts/NotificationContext'; import { logger } from '../utils/logger'; @@ -75,6 +75,9 @@ function ConnectionStatusBarWrapper() { export function GlobalComponents() { const location = useLocation(); + // ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载 + const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []); + return ( <> {/* Socket 连接状态条 */} @@ -89,9 +92,9 @@ export function GlobalComponents() { {/* 通知容器 */} - {/* Bytedesk在线客服 - 根据路径条件性显示 */} + {/* Bytedesk在线客服 - 使用缓存的配置对象 */} From 39978c57d5bf8483698a6c3fb9f50ab27c811e00 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 17:37:56 +0800 Subject: [PATCH 04/17] =?UTF-8?q?pref=EF=BC=9A=20src/views/LimitAnalyse=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=20"=E6=95=B0=E6=8D=AE=E5=88=86=E6=9E=90"?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=AD=E7=9A=84"=E7=83=AD=E8=AF=8D?= =?UTF-8?q?=E4=BA=91=E5=9B=BE"=20=E4=BE=9D=E8=B5=96=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- .../components/DataVisualizationComponents.js | 98 ++++++++++++++----- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index f339a183..f6d45165 100755 --- a/package.json +++ b/package.json @@ -22,7 +22,11 @@ "@splidejs/react-splide": "^0.7.12", "@tanstack/react-virtual": "^3.13.12", "@tippyjs/react": "^4.2.6", + "@visx/responsive": "^3.12.0", + "@visx/scale": "^3.12.0", + "@visx/text": "^3.12.0", "@visx/visx": "^3.12.0", + "@visx/wordcloud": "^3.12.0", "antd": "^5.27.4", "apexcharts": "^3.27.3", "axios": "^1.10.0", @@ -70,7 +74,6 @@ "react-tagsinput": "3.19.0", "react-to-print": "^2.13.0", "react-tsparticles": "^2.12.2", - "react-wordcloud": "^1.2.7", "recharts": "^3.1.2", "sass": "^1.49.9", "scroll-lock": "^2.1.5", diff --git a/src/views/LimitAnalyse/components/DataVisualizationComponents.js b/src/views/LimitAnalyse/components/DataVisualizationComponents.js index 4c02148f..63163f5d 100644 --- a/src/views/LimitAnalyse/components/DataVisualizationComponents.js +++ b/src/views/LimitAnalyse/components/DataVisualizationComponents.js @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect, useRef } from 'react'; import { Box, Card, @@ -39,7 +39,6 @@ import { import { getFormattedTextProps } from '../../../utils/textUtils'; import { ExternalLinkIcon } from '@chakra-ui/icons'; import RiskDisclaimer from '../../../components/RiskDisclaimer'; -import './WordCloud.css'; import { BarChart, Bar, PieChart, Pie, Cell, @@ -51,15 +50,44 @@ import { Treemap, Area, AreaChart, } from 'recharts'; -import ReactWordcloud from 'react-wordcloud'; +import { Wordcloud } from '@visx/wordcloud'; +import { scaleLog } from '@visx/scale'; +import { Text as VisxText } from '@visx/text'; + // 颜色配置 const CHART_COLORS = [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD', '#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB' ]; -// 词云图组件 +// 词云颜色 +const WORDCLOUD_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD']; + +// 词云图组件(使用 @visx/wordcloud,兼容 React 18) const WordCloud = ({ data }) => { + const [dimensions, setDimensions] = useState({ width: 0, height: 400 }); + const containerRef = useRef(null); + + // 监听容器尺寸变化 + useEffect(() => { + if (!containerRef.current) return; + + const updateDimensions = () => { + if (containerRef.current) { + setDimensions({ + width: containerRef.current.offsetWidth, + height: 400 + }); + } + }; + + updateDimensions(); + const resizeObserver = new ResizeObserver(updateDimensions); + resizeObserver.observe(containerRef.current); + + return () => resizeObserver.disconnect(); + }, []); + if (!data || data.length === 0) { return (
@@ -75,29 +103,51 @@ const WordCloud = ({ data }) => { value: item.value || item.count || 1 })); - const options = { - rotations: 2, - rotationAngles: [-90, 0], - fontFamily: 'Microsoft YaHei, sans-serif', - fontSizes: [16, 80], - fontWeight: 'bold', - padding: 3, - scale: 'sqrt', - }; + // 计算字体大小比例 + const fontScale = scaleLog({ + domain: [ + Math.min(...words.map(w => w.value)), + Math.max(...words.map(w => w.value)) + ], + range: [16, 80], + }); - const callbacks = { - getWordColor: () => { - const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD']; - return colors[Math.floor(Math.random() * colors.length)]; - } - }; + const fontSizeSetter = (datum) => fontScale(datum.value); return ( - + + {dimensions.width > 0 && ( + + 0.5} + > + {(cloudWords) => + cloudWords.map((w, i) => ( + + {w.text} + + )) + } + + + )} + ); }; From bc50d9fe3e51ff1500453e6e0908146a996ee041 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 17:46:07 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=9A=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82?= =?UTF-8?q?=20=E2=94=82=20=E2=94=82=20-=20=E2=9C=85=20React=2018=20Portal?= =?UTF-8?q?=20insertBefore=20=E9=94=99=E8=AF=AF=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=E2=94=82=20=E2=94=82=20?= =?UTF-8?q?=E2=94=82=20=E2=94=82=20-=20=E2=9C=85=20Ant=20Design=20Modal=20?= =?UTF-8?q?defaultProps=20=E5=BA=9F=E5=BC=83=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Auth/AuthModalManager.js | 118 +++++++----------------- 1 file changed, 35 insertions(+), 83 deletions(-) diff --git a/src/components/Auth/AuthModalManager.js b/src/components/Auth/AuthModalManager.js index 0e0323b9..358e8086 100644 --- a/src/components/Auth/AuthModalManager.js +++ b/src/components/Auth/AuthModalManager.js @@ -1,13 +1,7 @@ // src/components/Auth/AuthModalManager.js import React, { useEffect, useRef } from 'react'; -import { - Modal, - ModalOverlay, - ModalContent, - ModalBody, - ModalCloseButton, - useBreakpointValue -} from '@chakra-ui/react'; +import { Modal } from 'antd'; +import { useBreakpointValue } from '@chakra-ui/react'; import { useAuthModal } from '../../hooks/useAuthModal'; import AuthFormContent from './AuthFormContent'; import { trackEventAsync } from '@lib/posthog'; @@ -44,85 +38,43 @@ export default function AuthModalManager() { } }, [isAuthModalOpen]); - // 响应式尺寸配置 - const modalSize = useBreakpointValue({ - base: "md", // 移动端:md(不占满全屏) - sm: "md", // 小屏:md - md: "lg", // 中屏:lg - lg: "xl" // 大屏:xl(更紧凑) - }); - - // 响应式宽度配置 - const modalMaxW = useBreakpointValue({ - base: "90%", // 移动端:屏幕宽度的90% - sm: "90%", // 小屏:90% - md: "700px", // 中屏:固定700px - lg: "700px" // 大屏:固定700px - }); - - // 响应式水平边距 - const modalMx = useBreakpointValue({ - base: 4, // 移动端:左右各16px边距 - md: "auto" // 桌面端:自动居中 - }); - - // 响应式垂直边距 - const modalMy = useBreakpointValue({ - base: 8, // 移动端:上下各32px边距 - md: 8 // 桌面端:上下各32px边距 - }); - - // 条件渲染:只在打开时才渲染 Modal,避免创建不必要的 Portal - if (!isAuthModalOpen) { - return null; - } + // 响应式宽度配置(Ant Design Modal 使用数字或字符串) + const modalMaxW = useBreakpointValue( + { + base: "90%", // 移动端:屏幕宽度的90% + sm: "90%", // 小屏:90% + md: "700px", // 中屏:固定700px + lg: "700px" // 大屏:固定700px + }, + { fallback: "700px", ssr: false } + ); + // ✅ 使用 Ant Design Modal,完全避开 Chakra UI Portal 的 AnimatePresence 问题 + // Ant Design Modal 不使用 Framer Motion,不会有 React 18 并发渲染的 insertBefore 错误 return ( - {/* 半透明背景 + 模糊效果 */} - - - {/* 弹窗内容容器 */} - - {/* 关闭按钮 */} - - - {/* 弹窗主体内容 */} - - - - + ); } From 34bae3585885dfb6a6a0e51245c645e84fa0db55 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 17:59:03 +0800 Subject: [PATCH 06/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=9AH5=20=E6=B1=89=E5=A0=A1=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=E7=BD=AE=E8=B0=83=E6=95=B4=EF=BC=88=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=E5=A4=B4=E5=83=8F=E5=8F=B3=E4=BE=A7=EF=BC=89=20?= =?UTF-8?q?=E5=B9=B3=E6=9D=BF=E7=AB=AF=E6=98=BE=E7=A4=BA=20MoreMenu=20?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=E6=B1=89=E5=A0=A1=E8=8F=9C=E5=8D=95=20?= =?UTF-8?q?=E6=9C=AA=E7=99=BB=E5=BD=95=E6=97=B6=E4=B8=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=B1=89=E5=A0=A1=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Navbars/HomeNavbar.js | 5 ++- .../Navbars/components/NavbarActions/index.js | 41 ++++++++----------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index 05f07933..5fda71d9 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -44,7 +44,7 @@ import { useSubscription } from '../../hooks/useSubscription'; import { DesktopUserMenu, TabletUserMenu } from './components/UserMenu'; // Phase 4 优化: 提取的导航菜单组件 -import { DesktopNav, PersonalCenterMenu } from './components/Navigation'; +import { DesktopNav, MoreMenu, PersonalCenterMenu } from './components/Navigation'; // Phase 5 优化: 提取的移动端抽屉菜单组件 import { MobileDrawer } from './components/MobileDrawer'; @@ -67,6 +67,7 @@ export default function HomeNavbar() { const { isOpen, onOpen, onClose } = useDisclosure(); const navigate = useNavigate(); const isMobile = useBreakpointValue({ base: true, md: false }); + const isTablet = useBreakpointValue({ base: false, md: true, lg: false }); const isDesktop = useBreakpointValue({ base: false, md: false, lg: true }); const { user, isAuthenticated, logout, isLoading } = useAuth(); const { openAuthModal } = useAuthModal(); @@ -177,6 +178,8 @@ export default function HomeNavbar() { isAuthenticated={isAuthenticated} user={user} isDesktop={isDesktop} + isTablet={isTablet} + isMobile={isMobile} onMenuOpen={onOpen} handleLogout={handleLogout} watchlistQuotes={watchlistQuotes} diff --git a/src/components/Navbars/components/NavbarActions/index.js b/src/components/Navbars/components/NavbarActions/index.js index 7ca5b3c0..1680ea64 100644 --- a/src/components/Navbars/components/NavbarActions/index.js +++ b/src/components/Navbars/components/NavbarActions/index.js @@ -2,14 +2,14 @@ // Navbar 右侧功能区组件 import React, { memo } from 'react'; -import { HStack, Spinner, IconButton } from '@chakra-ui/react'; +import { HStack, Spinner, IconButton, Box } from '@chakra-ui/react'; import { HamburgerIcon } from '@chakra-ui/icons'; import ThemeToggleButton from '../ThemeToggleButton'; import LoginButton from '../LoginButton'; import CalendarButton from '../CalendarButton'; import { WatchlistMenu, FollowingEventsMenu } from '../FeatureMenus'; import { DesktopUserMenu, TabletUserMenu } from '../UserMenu'; -import { PersonalCenterMenu } from '../Navigation'; +import { PersonalCenterMenu, MoreMenu } from '../Navigation'; /** * Navbar 右侧功能区组件 @@ -20,8 +20,9 @@ import { PersonalCenterMenu } from '../Navigation'; * @param {boolean} props.isAuthenticated - 是否已登录 * @param {Object} props.user - 用户对象 * @param {boolean} props.isDesktop - 是否为桌面端 + * @param {boolean} props.isTablet - 是否为平板端 * @param {boolean} props.isMobile - 是否为移动端 - * @param {Function} props.onMenuOpen - 打开移动端菜单的回调 + * @param {Function} props.onMenuOpen - 打开移动端抽屉菜单的回调 * @param {Function} props.handleLogout - 登出回调 * @param {Array} props.watchlistQuotes - 自选股数据(用于 TabletUserMenu) * @param {Array} props.followingEvents - 关注事件数据(用于 TabletUserMenu) @@ -31,6 +32,7 @@ const NavbarActions = memo(({ isAuthenticated, user, isDesktop, + isTablet, isMobile, onMenuOpen, handleLogout, @@ -69,8 +71,15 @@ const NavbarActions = memo(({ /> )} - {/* 移动端汉堡菜单 - 在头像右侧 */} - {isMobile && ( + {/* 头像右侧的菜单 - 响应式(互斥逻辑,确保只渲染一个) */} + {isDesktop ? ( + // 桌面端:个人中心下拉菜单 + + ) : isTablet ? ( + // 平板端:MoreMenu 下拉菜单 + + ) : ( + // 移动端:汉堡菜单(打开抽屉) } variant="ghost" @@ -79,28 +88,10 @@ const NavbarActions = memo(({ size="md" /> )} - - {/* 个人中心下拉菜单 - 仅大屏显示 */} - {isDesktop && ( - - )} ) : ( - // 未登录状态 - 登录按钮 + 汉堡菜单 - - - - {/* 移动端汉堡菜单 - 未登录时也显示 */} - {isMobile && ( - } - variant="ghost" - onClick={onMenuOpen} - aria-label="打开菜单" - size="md" - /> - )} - + // 未登录状态 - 仅显示登录按钮 + )} ); From 9669d5709efedb92733d30ef6b893fab965a23c6 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:06:21 +0800 Subject: [PATCH 07/17] =?UTF-8?q?fix=EF=BC=9A=20=E5=9C=A8=20craco.config.j?= =?UTF-8?q?s=20=E4=B8=AD=E5=B0=86=20/bytedesk=20=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E7=A7=BB=E5=87=BA=20Mock=20=E6=A8=A1=E5=BC=8F=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E5=88=A4=E6=96=AD=20=E7=8E=B0=E5=9C=A8=20/bytedesk=20?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=A7=8B=E7=BB=88=E5=90=AF=E7=94=A8=EF=BC=8C?= =?UTF-8?q?=E6=8C=87=E5=90=91=20https://valuefrontier.cn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- craco.config.js | 28 ++++++------- .../components/BytedeskWidget.jsx | 39 +++++++++++++------ 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/craco.config.js b/craco.config.js index ea05ae1e..6db9ef20 100644 --- a/craco.config.js +++ b/craco.config.js @@ -284,9 +284,19 @@ module.exports = { }, // 代理配置:将 /api 请求代理到后端服务器 - // 注意:Mock 模式下禁用 proxy,让 MSW 拦截请求 - ...(isMockMode() ? {} : { - proxy: { + // 注意:Mock 模式下禁用 /api 和 /concept-api,让 MSW 拦截请求 + // 但 /bytedesk 始终启用(客服系统不走 Mock) + proxy: { + '/bytedesk': { + target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理 + changeOrigin: true, + secure: false, // 开发环境禁用 HTTPS 严格验证 + logLevel: 'debug', + ws: true, // 支持 WebSocket + // 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理 + }, + // Mock 模式下禁用其他代理 + ...(isMockMode() ? {} : { '/api': { target: 'http://49.232.185.254:5001', changeOrigin: true, @@ -300,15 +310,7 @@ module.exports = { logLevel: 'debug', pathRewrite: { '^/concept-api': '' }, }, - '/bytedesk': { - target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理 - changeOrigin: true, - secure: false, // 开发环境禁用 HTTPS 严格验证 - logLevel: 'debug', - ws: true, // 支持 WebSocket - // 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理 - }, - }, - }), + }), + }, }, }; diff --git a/src/bytedesk-integration/components/BytedeskWidget.jsx b/src/bytedesk-integration/components/BytedeskWidget.jsx index 2d0a831b..38a56855 100644 --- a/src/bytedesk-integration/components/BytedeskWidget.jsx +++ b/src/bytedesk-integration/components/BytedeskWidget.jsx @@ -78,26 +78,43 @@ const BytedeskWidget = ({ document.body.appendChild(script); scriptRef.current = script; - // 清理函数 + // 清理函数 - 增强错误处理,防止 React 18 StrictMode 双重清理报错 return () => { console.log('[Bytedesk] 清理Widget'); // 移除脚本 - if (scriptRef.current && document.body.contains(scriptRef.current)) { - document.body.removeChild(scriptRef.current); + try { + if (scriptRef.current && scriptRef.current.parentNode) { + scriptRef.current.parentNode.removeChild(scriptRef.current); + } + scriptRef.current = null; + } catch (error) { + console.warn('[Bytedesk] 移除脚本失败(可能已被移除):', error.message); } // 移除Widget DOM元素 - const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]'); - widgetElements.forEach(el => { - if (el && el.parentNode) { - el.parentNode.removeChild(el); - } - }); + try { + const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]'); + widgetElements.forEach(el => { + try { + if (el && el.parentNode && el.parentNode.contains(el)) { + el.parentNode.removeChild(el); + } + } catch (err) { + // 忽略单个元素移除失败(可能已被移除) + } + }); + } catch (error) { + console.warn('[Bytedesk] 清理Widget DOM元素失败:', error.message); + } // 清理全局对象 - if (window.BytedeskWeb) { - delete window.BytedeskWeb; + try { + if (window.BytedeskWeb) { + delete window.BytedeskWeb; + } + } catch (error) { + console.warn('[Bytedesk] 清理全局对象失败:', error.message); } }; }, [config, autoLoad, onLoad, onError]); From ceed71eca40f4c1de818dc5a8433c34bca56b38d Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:11:03 +0800 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=20TypeScript?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/metrics.ts | 349 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 src/types/metrics.ts diff --git a/src/types/metrics.ts b/src/types/metrics.ts new file mode 100644 index 00000000..d15fb72d --- /dev/null +++ b/src/types/metrics.ts @@ -0,0 +1,349 @@ +/** + * 性能指标相关的 TypeScript 类型定义 + * @module types/metrics + */ + +// ============================================================ +// Web Vitals 指标 +// ============================================================ + +/** + * Web Vitals 评级 + */ +export type MetricRating = 'good' | 'needs-improvement' | 'poor'; + +/** + * 单个 Web Vitals 指标 + */ +export interface WebVitalMetric { + /** 指标名称 (如 'LCP', 'FCP') */ + name: string; + /** 指标值(毫秒或分数) */ + value: number; + /** 评级 (good/needs-improvement/poor) */ + rating: MetricRating; + /** 与上周对比的差值 */ + delta?: number; + /** 测量时间戳 */ + timestamp: number; +} + +/** + * 完整的 Web Vitals 指标集合 + */ +export interface WebVitalsMetrics { + /** Largest Contentful Paint - 最大内容绘制时间 */ + LCP: WebVitalMetric; + /** First Contentful Paint - 首次内容绘制时间 */ + FCP: WebVitalMetric; + /** Cumulative Layout Shift - 累积布局偏移 */ + CLS: WebVitalMetric; + /** First Input Delay - 首次输入延迟 */ + FID: WebVitalMetric; + /** Time to First Byte - 首字节时间 */ + TTFB: WebVitalMetric; +} + +// ============================================================ +// 资源加载指标 +// ============================================================ + +/** + * 资源类型 + */ +export type ResourceType = 'script' | 'stylesheet' | 'image' | 'font' | 'document' | 'other'; + +/** + * 单个资源加载指标 + */ +export interface ResourceTiming { + /** 资源名称 */ + name: string; + /** 资源类型 */ + type: ResourceType; + /** 资源大小(字节) */ + size: number; + /** 加载耗时(毫秒) */ + duration: number; + /** 开始时间 */ + startTime: number; + /** 是否来自缓存 */ + fromCache: boolean; +} + +/** + * 资源加载统计 + */ +export interface ResourceStats { + /** JS 文件总大小(字节) */ + totalJsSize: number; + /** CSS 文件总大小(字节) */ + totalCssSize: number; + /** 图片总大小(字节) */ + totalImageSize: number; + /** 字体总大小(字节) */ + totalFontSize: number; + /** 总加载时间(毫秒) */ + totalLoadTime: number; + /** 缓存命中率(0-1) */ + cacheHitRate: number; + /** 详细的资源列表 */ + resources: ResourceTiming[]; +} + +// ============================================================ +// 首屏指标 +// ============================================================ + +/** + * 首屏性能指标 + */ +export interface FirstScreenMetrics { + /** Web Vitals 指标 */ + webVitals: Partial; + /** 资源加载统计 */ + resourceStats: ResourceStats; + /** 首屏可交互时间(毫秒) */ + timeToInteractive: number; + /** 骨架屏展示时长(毫秒) */ + skeletonDisplayDuration: number; + /** API 请求统计 */ + apiStats: ApiRequestStats; + /** 测量时间戳 */ + measuredAt: number; +} + +/** + * API 请求统计 + */ +export interface ApiRequestStats { + /** 总请求数 */ + totalRequests: number; + /** 平均响应时间(毫秒) */ + avgResponseTime: number; + /** 最慢的请求耗时(毫秒) */ + slowestRequest: number; + /** 失败的请求数 */ + failedRequests: number; + /** 详细的请求列表 */ + requests: ApiRequestTiming[]; +} + +/** + * 单个 API 请求时序 + */ +export interface ApiRequestTiming { + /** 请求 URL */ + url: string; + /** 请求方法 */ + method: string; + /** 响应时间(毫秒) */ + duration: number; + /** HTTP 状态码 */ + status: number; + /** 是否成功 */ + success: boolean; + /** 开始时间 */ + startTime: number; +} + +// ============================================================ +// 业务指标 +// ============================================================ + +/** + * 功能卡片点击统计 + */ +export interface FeatureCardMetrics { + /** 卡片 ID */ + cardId: string; + /** 卡片标题 */ + cardTitle: string; + /** 点击次数 */ + clicks: number; + /** 点击率(CTR) */ + clickRate: number; + /** 平均点击时间(距离页面加载的毫秒数) */ + avgClickTime: number; +} + +/** + * 首页业务指标 + */ +export interface HomePageBusinessMetrics { + /** 页面浏览次数(PV) */ + pageViews: number; + /** 独立访客数(UV) */ + uniqueVisitors: number; + /** 平均停留时长(秒) */ + avgSessionDuration: number; + /** 跳出率(0-1) */ + bounceRate: number; + /** 登录转化率(0-1) */ + loginConversionRate: number; + /** 功能卡片点击统计 */ + featureCards: FeatureCardMetrics[]; +} + +// ============================================================ +// PostHog 事件属性 +// ============================================================ + +/** + * PostHog 性能事件通用属性 + */ +export interface PerformanceEventProperties { + /** 页面类型 */ + page_type: string; + /** 设备类型 */ + device_type?: string; + /** 网络类型 */ + network_type?: string; + /** 浏览器 */ + browser?: string; + /** 是否已登录 */ + is_authenticated?: boolean; + /** 测量时间戳 */ + measured_at: number; +} + +/** + * Web Vitals 事件属性 + */ +export interface WebVitalsEventProperties extends PerformanceEventProperties { + /** 指标名称 */ + metric_name: 'LCP' | 'FCP' | 'CLS' | 'FID' | 'TTFB'; + /** 指标值 */ + metric_value: number; + /** 评级 */ + metric_rating: MetricRating; + /** 与上周对比 */ + delta?: number; +} + +/** + * 资源加载事件属性 + */ +export interface ResourceLoadEventProperties extends PerformanceEventProperties { + /** JS 总大小(KB) */ + js_size_kb: number; + /** CSS 总大小(KB) */ + css_size_kb: number; + /** 图片总大小(KB) */ + image_size_kb: number; + /** 总加载时间(秒) */ + total_load_time_s: number; + /** 缓存命中率(百分比) */ + cache_hit_rate_percent: number; + /** 资源总数 */ + resource_count: number; +} + +/** + * 首屏可交互事件属性 + */ +export interface FirstScreenInteractiveEventProperties extends PerformanceEventProperties { + /** 可交互时间(秒) */ + tti_seconds: number; + /** 骨架屏展示时长(秒) */ + skeleton_duration_seconds: number; + /** API 请求数 */ + api_request_count: number; + /** API 平均响应时间(毫秒) */ + api_avg_response_time_ms: number; +} + +// ============================================================ +// Hook 配置 +// ============================================================ + +/** + * useFirstScreenMetrics Hook 配置选项 + */ +export interface UseFirstScreenMetricsOptions { + /** 页面类型(用于区分不同页面) */ + pageType: string; + /** 是否启用控制台日志 */ + enableConsoleLog?: boolean; + /** 是否自动上报到 PostHog */ + trackToPostHog?: boolean; + /** 自定义事件属性 */ + customProperties?: Record; +} + +/** + * useFirstScreenMetrics Hook 返回值 + */ +export interface UseFirstScreenMetricsResult { + /** 是否正在测量 */ + isLoading: boolean; + /** 首屏指标(测量完成后) */ + metrics: FirstScreenMetrics | null; + /** 手动触发重新测量 */ + remeasure: () => void; + /** 导出指标为 JSON */ + exportMetrics: () => string; +} + +// ============================================================ +// 性能阈值配置 +// ============================================================ + +/** + * 单个指标的阈值配置 + */ +export interface MetricThreshold { + /** good 阈值(小于此值为 good) */ + good: number; + /** needs-improvement 阈值(小于此值为 needs-improvement,否则 poor) */ + needsImprovement: number; +} + +/** + * 所有性能指标的阈值配置 + */ +export interface PerformanceThresholds { + LCP: MetricThreshold; + FCP: MetricThreshold; + CLS: MetricThreshold; + FID: MetricThreshold; + TTFB: MetricThreshold; + TTI: MetricThreshold; +} + +// ============================================================ +// 调试面板 +// ============================================================ + +/** + * 调试面板配置 + */ +export interface DebugPanelConfig { + /** 是否启用 */ + enabled: boolean; + /** 默认是否展开 */ + defaultExpanded?: boolean; + /** 刷新间隔(毫秒) */ + refreshInterval?: number; + /** 位置 */ + position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; +} + +/** + * 调试面板数据 + */ +export interface DebugPanelData { + /** Web Vitals 指标 */ + webVitals: Partial; + /** 资源统计 */ + resources: ResourceStats; + /** API 统计 */ + api: ApiRequestStats; + /** 首屏时间 */ + firstScreen: { + tti: number; + skeletonDuration: number; + }; + /** 最后更新时间 */ + lastUpdated: number; +} From d6c7d64e5924c2121886afbdf76e8eb4ace9adcf Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:11:26 +0800 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E9=98=88=E5=80=BC=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/performanceThresholds.js | 306 +++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 src/constants/performanceThresholds.js diff --git a/src/constants/performanceThresholds.js b/src/constants/performanceThresholds.js new file mode 100644 index 00000000..28f2ee23 --- /dev/null +++ b/src/constants/performanceThresholds.js @@ -0,0 +1,306 @@ +/** + * 性能指标阈值配置 + * 基于 Google Web Vitals 标准和项目实际情况 + * + * 评级标准: + * - good: 绿色,性能优秀 + * - needs-improvement: 黄色,需要改进 + * - poor: 红色,性能较差 + * + * @see https://web.dev/defining-core-web-vitals-thresholds/ + */ + +// ============================================================ +// Web Vitals 官方阈值(Google 标准) +// ============================================================ + +/** + * Largest Contentful Paint (LCP) - 最大内容绘制 + * 衡量加载性能,理想情况下应在 2.5 秒内完成 + */ +export const LCP_THRESHOLDS = { + good: 2500, // < 2.5s 为优秀 + needsImprovement: 4000, // 2.5s - 4s 需要改进 + // > 4s 为较差 +}; + +/** + * First Contentful Paint (FCP) - 首次内容绘制 + * 衡量首次渲染任何内容的速度 + */ +export const FCP_THRESHOLDS = { + good: 1800, // < 1.8s 为优秀 + needsImprovement: 3000, // 1.8s - 3s 需要改进 + // > 3s 为较差 +}; + +/** + * Cumulative Layout Shift (CLS) - 累积布局偏移 + * 衡量视觉稳定性(无单位,分数值) + */ +export const CLS_THRESHOLDS = { + good: 0.1, // < 0.1 为优秀 + needsImprovement: 0.25, // 0.1 - 0.25 需要改进 + // > 0.25 为较差 +}; + +/** + * First Input Delay (FID) - 首次输入延迟 + * 衡量交互性能 + */ +export const FID_THRESHOLDS = { + good: 100, // < 100ms 为优秀 + needsImprovement: 300, // 100ms - 300ms 需要改进 + // > 300ms 为较差 +}; + +/** + * Time to First Byte (TTFB) - 首字节时间 + * 衡量服务器响应速度 + */ +export const TTFB_THRESHOLDS = { + good: 800, // < 0.8s 为优秀 + needsImprovement: 1800, // 0.8s - 1.8s 需要改进 + // > 1.8s 为较差 +}; + +// ============================================================ +// 自定义指标阈值(项目特定) +// ============================================================ + +/** + * Time to Interactive (TTI) - 首屏可交互时间 + * 自定义指标:从页面加载到用户可以交互的时间 + */ +export const TTI_THRESHOLDS = { + good: 3500, // < 3.5s 为优秀 + needsImprovement: 7300, // 3.5s - 7.3s 需要改进 + // > 7.3s 为较差 +}; + +/** + * 骨架屏展示时长阈值 + */ +export const SKELETON_DURATION_THRESHOLDS = { + good: 300, // < 0.3s 为优秀(骨架屏展示时间短) + needsImprovement: 1000, // 0.3s - 1s 需要改进 + // > 1s 为较差(骨架屏展示太久) +}; + +/** + * API 响应时间阈值 + */ +export const API_RESPONSE_TIME_THRESHOLDS = { + good: 500, // < 500ms 为优秀 + needsImprovement: 1500, // 500ms - 1.5s 需要改进 + // > 1.5s 为较差 +}; + +/** + * 资源加载时间阈值 + */ +export const RESOURCE_LOAD_TIME_THRESHOLDS = { + good: 2000, // < 2s 为优秀 + needsImprovement: 5000, // 2s - 5s 需要改进 + // > 5s 为较差 +}; + +/** + * Bundle 大小阈值(KB) + */ +export const BUNDLE_SIZE_THRESHOLDS = { + js: { + good: 500, // < 500KB 为优秀 + needsImprovement: 1000, // 500KB - 1MB 需要改进 + // > 1MB 为较差 + }, + css: { + good: 100, // < 100KB 为优秀 + needsImprovement: 200, // 100KB - 200KB 需要改进 + // > 200KB 为较差 + }, + image: { + good: 1500, // < 1.5MB 为优秀 + needsImprovement: 3000, // 1.5MB - 3MB 需要改进 + // > 3MB 为较差 + }, +}; + +/** + * 缓存命中率阈值(百分比) + */ +export const CACHE_HIT_RATE_THRESHOLDS = { + good: 80, // > 80% 为优秀 + needsImprovement: 50, // 50% - 80% 需要改进 + // < 50% 为较差 +}; + +// ============================================================ +// 综合阈值配置对象 +// ============================================================ + +/** + * 所有性能指标的阈值配置(用于类型化访问) + */ +export const PERFORMANCE_THRESHOLDS = { + LCP: LCP_THRESHOLDS, + FCP: FCP_THRESHOLDS, + CLS: CLS_THRESHOLDS, + FID: FID_THRESHOLDS, + TTFB: TTFB_THRESHOLDS, + TTI: TTI_THRESHOLDS, + SKELETON_DURATION: SKELETON_DURATION_THRESHOLDS, + API_RESPONSE_TIME: API_RESPONSE_TIME_THRESHOLDS, + RESOURCE_LOAD_TIME: RESOURCE_LOAD_TIME_THRESHOLDS, + BUNDLE_SIZE: BUNDLE_SIZE_THRESHOLDS, + CACHE_HIT_RATE: CACHE_HIT_RATE_THRESHOLDS, +}; + +// ============================================================ +// 工具函数 +// ============================================================ + +/** + * 根据指标值和阈值计算评级 + * @param {number} value - 指标值 + * @param {Object} thresholds - 阈值配置对象 { good, needsImprovement } + * @param {boolean} reverse - 是否反向评级(值越大越好,如缓存命中率) + * @returns {'good' | 'needs-improvement' | 'poor'} + */ +export const calculateRating = (value, thresholds, reverse = false) => { + if (!thresholds || typeof value !== 'number') { + return 'poor'; + } + + const { good, needsImprovement } = thresholds; + + if (reverse) { + // 反向评级:值越大越好(如缓存命中率) + if (value >= good) return 'good'; + if (value >= needsImprovement) return 'needs-improvement'; + return 'poor'; + } else { + // 正常评级:值越小越好(如加载时间) + if (value <= good) return 'good'; + if (value <= needsImprovement) return 'needs-improvement'; + return 'poor'; + } +}; + +/** + * 获取评级对应的颜色(Chakra UI 颜色方案) + * @param {'good' | 'needs-improvement' | 'poor'} rating + * @returns {string} Chakra UI 颜色名称 + */ +export const getRatingColor = (rating) => { + switch (rating) { + case 'good': + return 'green'; + case 'needs-improvement': + return 'yellow'; + case 'poor': + return 'red'; + default: + return 'gray'; + } +}; + +/** + * 获取评级对应的控制台颜色代码 + * @param {'good' | 'needs-improvement' | 'poor'} rating + * @returns {string} ANSI 颜色代码 + */ +export const getRatingConsoleColor = (rating) => { + switch (rating) { + case 'good': + return '\x1b[32m'; // 绿色 + case 'needs-improvement': + return '\x1b[33m'; // 黄色 + case 'poor': + return '\x1b[31m'; // 红色 + default: + return '\x1b[0m'; // 重置 + } +}; + +/** + * 获取评级对应的图标 + * @param {'good' | 'needs-improvement' | 'poor'} rating + * @returns {string} Emoji 图标 + */ +export const getRatingIcon = (rating) => { + switch (rating) { + case 'good': + return '✅'; + case 'needs-improvement': + return '⚠️'; + case 'poor': + return '❌'; + default: + return '❓'; + } +}; + +/** + * 格式化指标值(添加单位) + * @param {string} metricName - 指标名称 + * @param {number} value - 指标值 + * @returns {string} 格式化后的字符串 + */ +export const formatMetricValue = (metricName, value) => { + if (typeof value !== 'number' || isNaN(value)) { + return 'N/A'; + } + + switch (metricName) { + case 'LCP': + case 'FCP': + case 'FID': + case 'TTFB': + case 'TTI': + case 'SKELETON_DURATION': + case 'API_RESPONSE_TIME': + case 'RESOURCE_LOAD_TIME': + // 时间类指标:转换为秒或毫秒 + return value >= 1000 + ? `${(value / 1000).toFixed(2)}s` + : `${Math.round(value)}ms`; + + case 'CLS': + // CLS 是无单位的分数 + return value.toFixed(3); + + case 'CACHE_HIT_RATE': + // 百分比 + return `${value.toFixed(1)}%`; + + default: + // 默认保留两位小数 + return value.toFixed(2); + } +}; + +/** + * 批量计算所有 Web Vitals 指标的评级 + * @param {Object} metrics - 指标对象 { LCP: value, FCP: value, ... } + * @returns {Object} 评级对象 { LCP: 'good', FCP: 'needs-improvement', ... } + */ +export const calculateAllRatings = (metrics) => { + const ratings = {}; + + Object.entries(metrics).forEach(([metricName, value]) => { + const thresholds = PERFORMANCE_THRESHOLDS[metricName]; + if (thresholds) { + const isReverse = metricName === 'CACHE_HIT_RATE'; // 缓存命中率是反向评级 + ratings[metricName] = calculateRating(value, thresholds, isReverse); + } + }); + + return ratings; +}; + +// ============================================================ +// 默认导出 +// ============================================================ + +export default PERFORMANCE_THRESHOLDS; From 5f76530e8046e8600f3343976082df47f59ec791 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:12:34 +0800 Subject: [PATCH 10/17] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=20Web=20Vitals?= =?UTF-8?q?=20=E7=9B=91=E6=8E=A7=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/performance/webVitals.ts | 467 +++++++++++++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 src/utils/performance/webVitals.ts diff --git a/src/utils/performance/webVitals.ts b/src/utils/performance/webVitals.ts new file mode 100644 index 00000000..57769b1e --- /dev/null +++ b/src/utils/performance/webVitals.ts @@ -0,0 +1,467 @@ +/** + * Web Vitals 性能监控工具 + * 使用 PostHog 内置的性能监控 API,无需单独安装 web-vitals 库 + * + * 支持的指标: + * - LCP (Largest Contentful Paint) - 最大内容绘制 + * - FCP (First Contentful Paint) - 首次内容绘制 + * - CLS (Cumulative Layout Shift) - 累积布局偏移 + * - FID (First Input Delay) - 首次输入延迟 + * - TTFB (Time to First Byte) - 首字节时间 + * + * @module utils/performance/webVitals + */ + +import posthog from 'posthog-js'; +import type { WebVitalMetric, MetricRating, WebVitalsEventProperties } from '@/types/metrics'; +import { + calculateRating, + getRatingIcon, + getRatingConsoleColor, + formatMetricValue, + LCP_THRESHOLDS, + FCP_THRESHOLDS, + CLS_THRESHOLDS, + FID_THRESHOLDS, + TTFB_THRESHOLDS, +} from '@constants/performanceThresholds'; + +// ============================================================ +// 类型定义 +// ============================================================ + +interface WebVitalsConfig { + /** 是否启用控制台日志 (开发环境推荐) */ + enableConsoleLog?: boolean; + /** 是否上报到 PostHog (生产环境推荐) */ + trackToPostHog?: boolean; + /** 页面类型 (用于区分不同页面) */ + pageType?: string; + /** 自定义事件属性 */ + customProperties?: Record; +} + +interface PerformanceMetric { + name: string; + value: number; + rating: MetricRating; + entries: any[]; +} + +// ============================================================ +// 全局状态 +// ============================================================ + +let metricsCache: Map = new Map(); +let isObserving = false; + +// ============================================================ +// 核心函数 +// ============================================================ + +/** + * 获取阈值配置 + */ +const getThresholds = (metricName: string) => { + switch (metricName) { + case 'LCP': + return LCP_THRESHOLDS; + case 'FCP': + return FCP_THRESHOLDS; + case 'CLS': + return CLS_THRESHOLDS; + case 'FID': + return FID_THRESHOLDS; + case 'TTFB': + return TTFB_THRESHOLDS; + default: + return { good: 0, needsImprovement: 0 }; + } +}; + +/** + * 处理性能指标 + */ +const handleMetric = ( + metric: PerformanceMetric, + config: WebVitalsConfig +): WebVitalMetric => { + const { name, value, rating: browserRating } = metric; + const thresholds = getThresholds(name); + const rating = calculateRating(value, thresholds); + + const webVitalMetric: WebVitalMetric = { + name, + value, + rating, + timestamp: Date.now(), + }; + + // 缓存指标 + metricsCache.set(name, webVitalMetric); + + // 控制台输出 (开发环境) + if (config.enableConsoleLog) { + logMetricToConsole(webVitalMetric); + } + + // 上报到 PostHog (生产环境) + if (config.trackToPostHog && process.env.NODE_ENV === 'production') { + trackMetricToPostHog(webVitalMetric, config); + } + + return webVitalMetric; +}; + +/** + * 控制台输出指标 + */ +const logMetricToConsole = (metric: WebVitalMetric): void => { + const color = getRatingConsoleColor(metric.rating); + const icon = getRatingIcon(metric.rating); + const formattedValue = formatMetricValue(metric.name, metric.value); + const reset = '\x1b[0m'; + + console.log( + `${icon} ${color}${metric.name}: ${formattedValue} (${metric.rating})${reset}` + ); +}; + +/** + * 上报指标到 PostHog + */ +const trackMetricToPostHog = ( + metric: WebVitalMetric, + config: WebVitalsConfig +): void => { + try { + const eventProperties: WebVitalsEventProperties = { + metric_name: metric.name as any, + metric_value: metric.value, + metric_rating: metric.rating, + page_type: config.pageType || 'unknown', + device_type: getDeviceType(), + network_type: getNetworkType(), + browser: getBrowserInfo(), + is_authenticated: checkIfAuthenticated(), + measured_at: metric.timestamp, + ...config.customProperties, + }; + + posthog.capture(`Web Vitals - ${metric.name}`, eventProperties); + } catch (error) { + console.error('Failed to track metric to PostHog:', error); + } +}; + +// ============================================================ +// Performance Observer API (核心监控) +// ============================================================ + +/** + * 初始化 Web Vitals 监控 + * 使用浏览器原生 Performance Observer API + */ +export const initWebVitalsTracking = (config: WebVitalsConfig = {}): void => { + // 防止重复初始化 + if (isObserving) { + console.warn('⚠️ Web Vitals tracking already initialized'); + return; + } + + // 检查浏览器支持 + if (typeof window === 'undefined' || !('PerformanceObserver' in window)) { + console.warn('⚠️ PerformanceObserver not supported'); + return; + } + + const defaultConfig: WebVitalsConfig = { + enableConsoleLog: process.env.NODE_ENV === 'development', + trackToPostHog: process.env.NODE_ENV === 'production', + pageType: 'unknown', + ...config, + }; + + isObserving = true; + + if (defaultConfig.enableConsoleLog) { + console.group('🚀 Web Vitals Performance Tracking'); + console.log('Page Type:', defaultConfig.pageType); + console.log('Console Log:', defaultConfig.enableConsoleLog); + console.log('PostHog Tracking:', defaultConfig.trackToPostHog); + console.groupEnd(); + } + + // 监控 LCP (Largest Contentful Paint) + observeLCP(defaultConfig); + + // 监控 FCP (First Contentful Paint) + observeFCP(defaultConfig); + + // 监控 CLS (Cumulative Layout Shift) + observeCLS(defaultConfig); + + // 监控 FID (First Input Delay) + observeFID(defaultConfig); + + // 监控 TTFB (Time to First Byte) + observeTTFB(defaultConfig); +}; + +/** + * 监控 LCP - Largest Contentful Paint + */ +const observeLCP = (config: WebVitalsConfig): void => { + try { + const observer = new PerformanceObserver((entryList) => { + const entries = entryList.getEntries(); + const lastEntry = entries[entries.length - 1]; + + if (lastEntry) { + handleMetric( + { + name: 'LCP', + value: lastEntry.startTime, + rating: 'good', + entries: entries, + }, + config + ); + } + }); + + observer.observe({ type: 'largest-contentful-paint', buffered: true }); + } catch (error) { + console.error('Failed to observe LCP:', error); + } +}; + +/** + * 监控 FCP - First Contentful Paint + */ +const observeFCP = (config: WebVitalsConfig): void => { + try { + const observer = new PerformanceObserver((entryList) => { + const entries = entryList.getEntries(); + + entries.forEach((entry) => { + if (entry.name === 'first-contentful-paint') { + handleMetric( + { + name: 'FCP', + value: entry.startTime, + rating: 'good', + entries: [entry], + }, + config + ); + } + }); + }); + + observer.observe({ type: 'paint', buffered: true }); + } catch (error) { + console.error('Failed to observe FCP:', error); + } +}; + +/** + * 监控 CLS - Cumulative Layout Shift + */ +const observeCLS = (config: WebVitalsConfig): void => { + try { + let clsValue = 0; + let clsEntries: any[] = []; + + const observer = new PerformanceObserver((entryList) => { + const entries = entryList.getEntries(); + + entries.forEach((entry: any) => { + if (!entry.hadRecentInput) { + clsValue += entry.value; + clsEntries.push(entry); + } + }); + + handleMetric( + { + name: 'CLS', + value: clsValue, + rating: 'good', + entries: clsEntries, + }, + config + ); + }); + + observer.observe({ type: 'layout-shift', buffered: true }); + } catch (error) { + console.error('Failed to observe CLS:', error); + } +}; + +/** + * 监控 FID - First Input Delay + */ +const observeFID = (config: WebVitalsConfig): void => { + try { + const observer = new PerformanceObserver((entryList) => { + const entries = entryList.getEntries(); + const firstInput = entries[0]; + + if (firstInput) { + const fidValue = (firstInput as any).processingStart - firstInput.startTime; + + handleMetric( + { + name: 'FID', + value: fidValue, + rating: 'good', + entries: [firstInput], + }, + config + ); + } + }); + + observer.observe({ type: 'first-input', buffered: true }); + } catch (error) { + console.error('Failed to observe FID:', error); + } +}; + +/** + * 监控 TTFB - Time to First Byte + */ +const observeTTFB = (config: WebVitalsConfig): void => { + try { + const navigationEntry = performance.getEntriesByType('navigation')[0] as any; + + if (navigationEntry) { + const ttfb = navigationEntry.responseStart - navigationEntry.requestStart; + + handleMetric( + { + name: 'TTFB', + value: ttfb, + rating: 'good', + entries: [navigationEntry], + }, + config + ); + } + } catch (error) { + console.error('Failed to measure TTFB:', error); + } +}; + +// ============================================================ +// 辅助函数 +// ============================================================ + +/** + * 获取设备类型 + */ +const getDeviceType = (): string => { + const ua = navigator.userAgent; + if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) { + return 'tablet'; + } + if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) { + return 'mobile'; + } + return 'desktop'; +}; + +/** + * 获取网络类型 + */ +const getNetworkType = (): string => { + const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection; + return connection?.effectiveType || 'unknown'; +}; + +/** + * 获取浏览器信息 + */ +const getBrowserInfo = (): string => { + const ua = navigator.userAgent; + if (ua.includes('Chrome')) return 'Chrome'; + if (ua.includes('Safari')) return 'Safari'; + if (ua.includes('Firefox')) return 'Firefox'; + if (ua.includes('Edge')) return 'Edge'; + return 'Unknown'; +}; + +/** + * 检查用户是否已登录 + */ +const checkIfAuthenticated = (): boolean => { + // 从 localStorage 或 cookie 中检查认证状态 + return !!localStorage.getItem('has_visited'); // 示例逻辑 +}; + +// ============================================================ +// 导出工具函数 +// ============================================================ + +/** + * 获取缓存的指标 + */ +export const getCachedMetrics = (): Map => { + return metricsCache; +}; + +/** + * 获取单个指标 + */ +export const getCachedMetric = (metricName: string): WebVitalMetric | undefined => { + return metricsCache.get(metricName); +}; + +/** + * 清除缓存 + */ +export const clearMetricsCache = (): void => { + metricsCache.clear(); +}; + +/** + * 导出所有指标为 JSON + */ +export const exportMetricsAsJSON = (): string => { + const metrics = Array.from(metricsCache.entries()).map(([key, value]) => ({ + name: key, + ...value, + })); + + return JSON.stringify(metrics, null, 2); +}; + +/** + * 在控制台输出完整报告 + */ +export const logPerformanceReport = (): void => { + console.group('📊 Web Vitals Performance Report'); + console.log('━'.repeat(50)); + + metricsCache.forEach((metric) => { + logMetricToConsole(metric); + }); + + console.log('━'.repeat(50)); + console.groupEnd(); +}; + +// ============================================================ +// 默认导出 +// ============================================================ + +export default { + initWebVitalsTracking, + getCachedMetrics, + getCachedMetric, + clearMetricsCache, + exportMetricsAsJSON, + logPerformanceReport, +}; From e34f5593b44fae9afd89efd4f1973d16f86c4452 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:12:58 +0800 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=8A=A0=E8=BD=BD=E7=9B=91=E6=8E=A7=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/performance/resourceMonitor.ts | 435 +++++++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 src/utils/performance/resourceMonitor.ts diff --git a/src/utils/performance/resourceMonitor.ts b/src/utils/performance/resourceMonitor.ts new file mode 100644 index 00000000..39463ca1 --- /dev/null +++ b/src/utils/performance/resourceMonitor.ts @@ -0,0 +1,435 @@ +/** + * 资源加载监控工具 + * 使用 Performance API 监控 JS、CSS、图片、字体等资源的加载情况 + * + * 功能: + * - 监控资源加载时间 + * - 统计 bundle 大小 + * - 计算缓存命中率 + * - 监控 API 请求响应时间 + * + * @module utils/performance/resourceMonitor + */ + +import posthog from 'posthog-js'; +import type { + ResourceTiming, + ResourceStats, + ResourceType, + ApiRequestStats, + ApiRequestTiming, + ResourceLoadEventProperties, +} from '@/types/metrics'; +import { + calculateRating, + getRatingIcon, + getRatingConsoleColor, + BUNDLE_SIZE_THRESHOLDS, + RESOURCE_LOAD_TIME_THRESHOLDS, + CACHE_HIT_RATE_THRESHOLDS, + API_RESPONSE_TIME_THRESHOLDS, +} from '@constants/performanceThresholds'; + +// ============================================================ +// 类型定义 +// ============================================================ + +interface ResourceMonitorConfig { + /** 是否启用控制台日志 */ + enableConsoleLog?: boolean; + /** 是否上报到 PostHog */ + trackToPostHog?: boolean; + /** 页面类型 */ + pageType?: string; + /** 自定义事件属性 */ + customProperties?: Record; +} + +// ============================================================ +// 全局状态 +// ============================================================ + +let resourceStatsCache: ResourceStats | null = null; +let apiStatsCache: ApiRequestStats | null = null; + +// ============================================================ +// 资源类型判断 +// ============================================================ + +/** + * 根据资源 URL 判断资源类型 + */ +const getResourceType = (url: string, initiatorType: string): ResourceType => { + const extension = url.split('.').pop()?.toLowerCase() || ''; + + // 根据文件扩展名判断 + if (['js', 'mjs', 'jsx'].includes(extension)) return 'script'; + if (['css', 'scss', 'sass', 'less'].includes(extension)) return 'stylesheet'; + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico'].includes(extension)) return 'image'; + if (['woff', 'woff2', 'ttf', 'otf', 'eot'].includes(extension)) return 'font'; + if (initiatorType === 'xmlhttprequest' || initiatorType === 'fetch') return 'other'; // API 请求 + if (url.includes('/api/')) return 'other'; // API 请求 + if (extension === 'html' || initiatorType === 'navigation') return 'document'; + + return 'other'; +}; + +/** + * 判断资源是否来自缓存 + */ +const isFromCache = (entry: PerformanceResourceTiming): boolean => { + // transferSize 为 0 表示来自缓存(或 304 Not Modified) + return entry.transferSize === 0 && entry.decodedBodySize > 0; +}; + +// ============================================================ +// 资源统计 +// ============================================================ + +/** + * 收集所有资源的加载信息 + */ +export const collectResourceStats = (config: ResourceMonitorConfig = {}): ResourceStats => { + const defaultConfig: ResourceMonitorConfig = { + enableConsoleLog: process.env.NODE_ENV === 'development', + trackToPostHog: process.env.NODE_ENV === 'production', + pageType: 'unknown', + ...config, + }; + + try { + // 获取所有资源条目 + const resourceEntries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; + + const resources: ResourceTiming[] = []; + let totalJsSize = 0; + let totalCssSize = 0; + let totalImageSize = 0; + let totalFontSize = 0; + let totalLoadTime = 0; + let cacheHits = 0; + + resourceEntries.forEach((entry) => { + const type = getResourceType(entry.name, entry.initiatorType); + const fromCache = isFromCache(entry); + const size = entry.transferSize || entry.encodedBodySize || 0; + const duration = entry.duration; + + const resourceTiming: ResourceTiming = { + name: entry.name, + type, + size, + duration, + startTime: entry.startTime, + fromCache, + }; + + resources.push(resourceTiming); + + // 统计各类资源的总大小 + switch (type) { + case 'script': + totalJsSize += size; + break; + case 'stylesheet': + totalCssSize += size; + break; + case 'image': + totalImageSize += size; + break; + case 'font': + totalFontSize += size; + break; + } + + totalLoadTime += duration; + + if (fromCache) { + cacheHits++; + } + }); + + const cacheHitRate = resources.length > 0 ? (cacheHits / resources.length) * 100 : 0; + + const stats: ResourceStats = { + totalJsSize, + totalCssSize, + totalImageSize, + totalFontSize, + totalLoadTime, + cacheHitRate, + resources, + }; + + // 缓存结果 + resourceStatsCache = stats; + + // 控制台输出 + if (defaultConfig.enableConsoleLog) { + logResourceStatsToConsole(stats); + } + + // 上报到 PostHog + if (defaultConfig.trackToPostHog && process.env.NODE_ENV === 'production') { + trackResourceStatsToPostHog(stats, defaultConfig); + } + + return stats; + } catch (error) { + console.error('Failed to collect resource stats:', error); + return { + totalJsSize: 0, + totalCssSize: 0, + totalImageSize: 0, + totalFontSize: 0, + totalLoadTime: 0, + cacheHitRate: 0, + resources: [], + }; + } +}; + +/** + * 控制台输出资源统计 + */ +const logResourceStatsToConsole = (stats: ResourceStats): void => { + console.group('📦 Resource Loading Statistics'); + console.log('━'.repeat(50)); + + // JS Bundle + const jsRating = calculateRating(stats.totalJsSize / 1024, BUNDLE_SIZE_THRESHOLDS.js); + const jsColor = getRatingConsoleColor(jsRating); + const jsIcon = getRatingIcon(jsRating); + console.log( + `${jsIcon} ${jsColor}JS Bundle: ${(stats.totalJsSize / 1024).toFixed(2)}KB (${jsRating})\x1b[0m` + ); + + // CSS Bundle + const cssRating = calculateRating(stats.totalCssSize / 1024, BUNDLE_SIZE_THRESHOLDS.css); + const cssColor = getRatingConsoleColor(cssRating); + const cssIcon = getRatingIcon(cssRating); + console.log( + `${cssIcon} ${cssColor}CSS Bundle: ${(stats.totalCssSize / 1024).toFixed(2)}KB (${cssRating})\x1b[0m` + ); + + // Images + const imageRating = calculateRating(stats.totalImageSize / 1024, BUNDLE_SIZE_THRESHOLDS.image); + const imageColor = getRatingConsoleColor(imageRating); + const imageIcon = getRatingIcon(imageRating); + console.log( + `${imageIcon} ${imageColor}Images: ${(stats.totalImageSize / 1024).toFixed(2)}KB (${imageRating})\x1b[0m` + ); + + // Fonts + console.log(`📝 Fonts: ${(stats.totalFontSize / 1024).toFixed(2)}KB`); + + // Total Load Time + const loadTimeRating = calculateRating(stats.totalLoadTime, RESOURCE_LOAD_TIME_THRESHOLDS); + const loadTimeColor = getRatingConsoleColor(loadTimeRating); + const loadTimeIcon = getRatingIcon(loadTimeRating); + console.log( + `${loadTimeIcon} ${loadTimeColor}Total Load Time: ${(stats.totalLoadTime / 1000).toFixed(2)}s (${loadTimeRating})\x1b[0m` + ); + + // Cache Hit Rate + const cacheRating = calculateRating(stats.cacheHitRate, CACHE_HIT_RATE_THRESHOLDS, true); + const cacheColor = getRatingConsoleColor(cacheRating); + const cacheIcon = getRatingIcon(cacheRating); + console.log( + `${cacheIcon} ${cacheColor}Cache Hit Rate: ${stats.cacheHitRate.toFixed(1)}% (${cacheRating})\x1b[0m` + ); + + console.log('━'.repeat(50)); + console.groupEnd(); +}; + +/** + * 上报资源统计到 PostHog + */ +const trackResourceStatsToPostHog = ( + stats: ResourceStats, + config: ResourceMonitorConfig +): void => { + try { + const eventProperties: ResourceLoadEventProperties = { + js_size_kb: stats.totalJsSize / 1024, + css_size_kb: stats.totalCssSize / 1024, + image_size_kb: stats.totalImageSize / 1024, + total_load_time_s: stats.totalLoadTime / 1000, + cache_hit_rate_percent: stats.cacheHitRate, + resource_count: stats.resources.length, + page_type: config.pageType || 'unknown', + measured_at: Date.now(), + ...config.customProperties, + }; + + posthog.capture('Resource Load Complete', eventProperties); + } catch (error) { + console.error('Failed to track resource stats to PostHog:', error); + } +}; + +// ============================================================ +// API 请求监控 +// ============================================================ + +/** + * 收集 API 请求统计 + */ +export const collectApiStats = (config: ResourceMonitorConfig = {}): ApiRequestStats => { + try { + const resourceEntries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; + + // 筛选 API 请求(通过 URL 包含 /api/ 或 initiatorType 为 fetch/xmlhttprequest) + const apiEntries = resourceEntries.filter( + (entry) => + entry.name.includes('/api/') || + entry.initiatorType === 'fetch' || + entry.initiatorType === 'xmlhttprequest' + ); + + const requests: ApiRequestTiming[] = apiEntries.map((entry) => ({ + url: entry.name, + method: 'GET', // Performance API 无法获取方法,默认 GET + duration: entry.duration, + status: 200, // Performance API 无法获取状态码,假设成功 + success: true, + startTime: entry.startTime, + })); + + const totalRequests = requests.length; + const avgResponseTime = + totalRequests > 0 + ? requests.reduce((sum, req) => sum + req.duration, 0) / totalRequests + : 0; + const slowestRequest = + totalRequests > 0 ? Math.max(...requests.map((req) => req.duration)) : 0; + const failedRequests = 0; // Performance API 无法判断失败 + + const stats: ApiRequestStats = { + totalRequests, + avgResponseTime, + slowestRequest, + failedRequests, + requests, + }; + + // 缓存结果 + apiStatsCache = stats; + + // 控制台输出 + if (config.enableConsoleLog) { + logApiStatsToConsole(stats); + } + + return stats; + } catch (error) { + console.error('Failed to collect API stats:', error); + return { + totalRequests: 0, + avgResponseTime: 0, + slowestRequest: 0, + failedRequests: 0, + requests: [], + }; + } +}; + +/** + * 控制台输出 API 统计 + */ +const logApiStatsToConsole = (stats: ApiRequestStats): void => { + console.group('🌐 API Request Statistics'); + console.log('━'.repeat(50)); + + console.log(`📊 Total Requests: ${stats.totalRequests}`); + + // Average Response Time + const avgRating = calculateRating(stats.avgResponseTime, API_RESPONSE_TIME_THRESHOLDS); + const avgColor = getRatingConsoleColor(avgRating); + const avgIcon = getRatingIcon(avgRating); + console.log( + `${avgIcon} ${avgColor}Avg Response Time: ${stats.avgResponseTime.toFixed(0)}ms (${avgRating})\x1b[0m` + ); + + // Slowest Request + const slowestRating = calculateRating(stats.slowestRequest, API_RESPONSE_TIME_THRESHOLDS); + const slowestColor = getRatingConsoleColor(slowestRating); + const slowestIcon = getRatingIcon(slowestRating); + console.log( + `${slowestIcon} ${slowestColor}Slowest Request: ${stats.slowestRequest.toFixed(0)}ms (${slowestRating})\x1b[0m` + ); + + console.log(`❌ Failed Requests: ${stats.failedRequests}`); + + console.log('━'.repeat(50)); + console.groupEnd(); +}; + +// ============================================================ +// 导出工具函数 +// ============================================================ + +/** + * 获取缓存的资源统计 + */ +export const getCachedResourceStats = (): ResourceStats | null => { + return resourceStatsCache; +}; + +/** + * 获取缓存的 API 统计 + */ +export const getCachedApiStats = (): ApiRequestStats | null => { + return apiStatsCache; +}; + +/** + * 清除缓存 + */ +export const clearStatsCache = (): void => { + resourceStatsCache = null; + apiStatsCache = null; +}; + +/** + * 导出资源统计为 JSON + */ +export const exportResourceStatsAsJSON = (): string => { + return JSON.stringify(resourceStatsCache, null, 2); +}; + +/** + * 导出 API 统计为 JSON + */ +export const exportApiStatsAsJSON = (): string => { + return JSON.stringify(apiStatsCache, null, 2); +}; + +/** + * 在控制台输出完整报告 + */ +export const logPerformanceReport = (): void => { + if (resourceStatsCache) { + logResourceStatsToConsole(resourceStatsCache); + } + + if (apiStatsCache) { + logApiStatsToConsole(apiStatsCache); + } +}; + +// ============================================================ +// 默认导出 +// ============================================================ + +export default { + collectResourceStats, + collectApiStats, + getCachedResourceStats, + getCachedApiStats, + clearStatsCache, + exportResourceStatsAsJSON, + exportApiStatsAsJSON, + logPerformanceReport, +}; From 7f021dcfa0939ad4ad4caa180c741fdb52eab916 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:13:33 +0800 Subject: [PATCH 12/17] =?UTF-8?q?feat;=20=E5=88=9B=E5=BB=BA=E9=A6=96?= =?UTF-8?q?=E5=B1=8F=E6=8C=87=E6=A0=87=E6=94=B6=E9=9B=86=20Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/components/HomePageSkeleton.tsx | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/views/Home/components/HomePageSkeleton.tsx diff --git a/src/views/Home/components/HomePageSkeleton.tsx b/src/views/Home/components/HomePageSkeleton.tsx new file mode 100644 index 00000000..cc90852a --- /dev/null +++ b/src/views/Home/components/HomePageSkeleton.tsx @@ -0,0 +1,194 @@ +/** + * 首页骨架屏组件 + * 模拟首页的 6 个功能卡片布局,减少白屏感知时间 + * + * 使用 Chakra UI 的 Skeleton 组件 + * + * @module views/Home/components/HomePageSkeleton + */ + +import React from 'react'; +import { + Box, + Container, + SimpleGrid, + Skeleton, + SkeletonText, + VStack, + HStack, + useColorModeValue, +} from '@chakra-ui/react'; + +// ============================================================ +// 类型定义 +// ============================================================ + +interface HomePageSkeletonProps { + /** 是否显示动画效果 */ + isAnimated?: boolean; + /** 骨架屏速度(秒) */ + speed?: number; +} + +// ============================================================ +// 单个卡片骨架 +// ============================================================ + +const FeatureCardSkeleton: React.FC<{ isFeatured?: boolean }> = ({ isFeatured = false }) => { + const bg = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.700'); + + return ( + + + {/* 图标骨架 */} + + + {/* 标题骨架 */} + + + {/* 描述骨架 */} + + + {/* 按钮骨架 */} + + + + {/* Featured 徽章骨架 */} + {isFeatured && ( + + )} + + ); +}; + +// ============================================================ +// 主骨架组件 +// ============================================================ + +export const HomePageSkeleton: React.FC = ({ + isAnimated = true, + speed = 0.8, +}) => { + const containerBg = useColorModeValue('gray.50', 'gray.900'); + + return ( + + + + {/* 顶部标题区域骨架 */} + + {/* 主标题 */} + + + {/* 副标题 */} + + + {/* CTA 按钮 */} + + + + + + + {/* 功能卡片网格骨架 */} + + {/* 第一张卡片 - Featured (新闻中心) */} + + + + + {/* 其余 5 张卡片 */} + {[1, 2, 3, 4, 5].map((index) => ( + + ))} + + + {/* 底部装饰元素骨架 */} + + {[1, 2, 3].map((index) => ( + + + + + ))} + + + + + ); +}; + +// ============================================================ +// 默认导出 +// ============================================================ + +export default HomePageSkeleton; From 2dec587d376924b7363cb4265d64a66b168518be Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:13:53 +0800 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20PostHog=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/constants.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/constants.js b/src/lib/constants.js index 0efb1400..f794027c 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -225,9 +225,19 @@ export const SPECIAL_EVENTS = { API_ERROR: 'API Error', NOT_FOUND_404: '404 Not Found', - // Performance + // Performance - Web Vitals PAGE_LOAD_TIME: 'Page Load Time', API_RESPONSE_TIME: 'API Response Time', + WEB_VITALS_LCP: 'Web Vitals - LCP', // Largest Contentful Paint + WEB_VITALS_FCP: 'Web Vitals - FCP', // First Contentful Paint + WEB_VITALS_CLS: 'Web Vitals - CLS', // Cumulative Layout Shift + WEB_VITALS_FID: 'Web Vitals - FID', // First Input Delay + WEB_VITALS_TTFB: 'Web Vitals - TTFB', // Time to First Byte + + // Performance - First Screen + FIRST_SCREEN_INTERACTIVE: 'First Screen Interactive', // 首屏可交互时间 + RESOURCE_LOAD_COMPLETE: 'Resource Load Complete', // 资源加载完成 + SKELETON_DISPLAYED: 'Skeleton Displayed', // 骨架屏展示 // Scroll depth SCROLL_DEPTH_25: 'Scroll Depth 25%', From ed584b72d46168ef28be59cca308465e418e336d Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:14:29 +0800 Subject: [PATCH 14/17] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E6=95=B4?= =?UTF-8?q?=E5=90=88=E6=89=80=E6=9C=89=E6=8C=87=E6=A0=87=E7=9A=84=20Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useFirstScreenMetrics.ts | 291 +++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 src/hooks/useFirstScreenMetrics.ts diff --git a/src/hooks/useFirstScreenMetrics.ts b/src/hooks/useFirstScreenMetrics.ts new file mode 100644 index 00000000..58b1afc6 --- /dev/null +++ b/src/hooks/useFirstScreenMetrics.ts @@ -0,0 +1,291 @@ +/** + * 首屏性能指标收集 Hook + * 整合 Web Vitals、资源加载、API 请求等指标 + * + * 使用示例: + * ```tsx + * const { metrics, isLoading, remeasure, exportMetrics } = useFirstScreenMetrics({ + * pageType: 'home', + * enableConsoleLog: process.env.NODE_ENV === 'development' + * }); + * ``` + * + * @module hooks/useFirstScreenMetrics + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { initWebVitalsTracking, getCachedMetrics } from '@utils/performance/webVitals'; +import { collectResourceStats, collectApiStats } from '@utils/performance/resourceMonitor'; +import posthog from 'posthog-js'; +import type { + FirstScreenMetrics, + UseFirstScreenMetricsOptions, + UseFirstScreenMetricsResult, + FirstScreenInteractiveEventProperties, +} from '@/types/metrics'; + +// ============================================================ +// Hook 实现 +// ============================================================ + +/** + * 首屏性能指标收集 Hook + */ +export const useFirstScreenMetrics = ( + options: UseFirstScreenMetricsOptions +): UseFirstScreenMetricsResult => { + const { + pageType, + enableConsoleLog = process.env.NODE_ENV === 'development', + trackToPostHog = process.env.NODE_ENV === 'production', + customProperties = {}, + } = options; + + const [isLoading, setIsLoading] = useState(true); + const [metrics, setMetrics] = useState(null); + + // 使用 ref 记录页面加载开始时间 + const pageLoadStartRef = useRef(performance.now()); + const skeletonStartRef = useRef(performance.now()); + const hasInitializedRef = useRef(false); + + /** + * 收集所有首屏指标 + */ + const collectAllMetrics = useCallback((): FirstScreenMetrics => { + try { + // 1. 初始化 Web Vitals 监控 + initWebVitalsTracking({ + enableConsoleLog, + trackToPostHog: false, // Web Vitals 自己会上报,这里不重复 + pageType, + customProperties, + }); + + // 2. 获取 Web Vitals 指标(延迟获取,等待 LCP/FCP 等指标完成) + const webVitalsCache = getCachedMetrics(); + const webVitals = Object.fromEntries(webVitalsCache.entries()); + + // 3. 收集资源加载统计 + const resourceStats = collectResourceStats({ + enableConsoleLog, + trackToPostHog: false, // 避免重复上报 + pageType, + customProperties, + }); + + // 4. 收集 API 请求统计 + const apiStats = collectApiStats({ + enableConsoleLog, + trackToPostHog: false, + pageType, + customProperties, + }); + + // 5. 计算首屏可交互时间(TTI) + const now = performance.now(); + const timeToInteractive = now - pageLoadStartRef.current; + + // 6. 计算骨架屏展示时长 + const skeletonDisplayDuration = now - skeletonStartRef.current; + + const firstScreenMetrics: FirstScreenMetrics = { + webVitals, + resourceStats, + apiStats, + timeToInteractive, + skeletonDisplayDuration, + measuredAt: Date.now(), + }; + + return firstScreenMetrics; + } catch (error) { + console.error('Failed to collect first screen metrics:', error); + throw error; + } + }, [pageType, enableConsoleLog, trackToPostHog, customProperties]); + + /** + * 上报首屏可交互事件到 PostHog + */ + const trackFirstScreenInteractive = useCallback( + (metrics: FirstScreenMetrics) => { + if (!trackToPostHog || process.env.NODE_ENV !== 'production') { + return; + } + + try { + const eventProperties: FirstScreenInteractiveEventProperties = { + tti_seconds: metrics.timeToInteractive / 1000, + skeleton_duration_seconds: metrics.skeletonDisplayDuration / 1000, + api_request_count: metrics.apiStats.totalRequests, + api_avg_response_time_ms: metrics.apiStats.avgResponseTime, + page_type: pageType, + measured_at: metrics.measuredAt, + ...customProperties, + }; + + posthog.capture('First Screen Interactive', eventProperties); + + if (enableConsoleLog) { + console.log('📊 Tracked First Screen Interactive to PostHog', eventProperties); + } + } catch (error) { + console.error('Failed to track first screen interactive:', error); + } + }, + [pageType, trackToPostHog, enableConsoleLog, customProperties] + ); + + /** + * 手动触发重新测量 + */ + const remeasure = useCallback(() => { + setIsLoading(true); + + // 重置计时器 + pageLoadStartRef.current = performance.now(); + skeletonStartRef.current = performance.now(); + + // 延迟收集指标(等待 Web Vitals 完成) + setTimeout(() => { + try { + const newMetrics = collectAllMetrics(); + setMetrics(newMetrics); + trackFirstScreenInteractive(newMetrics); + + if (enableConsoleLog) { + console.group('🎯 First Screen Metrics (Re-measured)'); + console.log('TTI:', `${(newMetrics.timeToInteractive / 1000).toFixed(2)}s`); + console.log('Skeleton Duration:', `${(newMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`); + console.log('API Requests:', newMetrics.apiStats.totalRequests); + console.groupEnd(); + } + } catch (error) { + console.error('Failed to remeasure metrics:', error); + } finally { + setIsLoading(false); + } + }, 1000); // 延迟 1 秒收集 + }, [collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]); + + /** + * 导出指标为 JSON + */ + const exportMetrics = useCallback((): string => { + if (!metrics) { + return JSON.stringify({ error: 'No metrics available' }, null, 2); + } + + return JSON.stringify(metrics, null, 2); + }, [metrics]); + + /** + * 初始化:在组件挂载时自动收集指标 + */ + useEffect(() => { + // 防止重复初始化 + if (hasInitializedRef.current) { + return; + } + + hasInitializedRef.current = true; + + if (enableConsoleLog) { + console.log('🚀 useFirstScreenMetrics initialized', { pageType }); + } + + // 延迟收集指标,等待页面渲染完成和 Web Vitals 指标就绪 + const timeoutId = setTimeout(() => { + try { + const firstScreenMetrics = collectAllMetrics(); + setMetrics(firstScreenMetrics); + trackFirstScreenInteractive(firstScreenMetrics); + + if (enableConsoleLog) { + console.group('🎯 First Screen Metrics'); + console.log('━'.repeat(50)); + console.log(`✅ TTI: ${(firstScreenMetrics.timeToInteractive / 1000).toFixed(2)}s`); + console.log(`✅ Skeleton Duration: ${(firstScreenMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`); + console.log(`✅ API Requests: ${firstScreenMetrics.apiStats.totalRequests}`); + console.log(`✅ API Avg Response: ${firstScreenMetrics.apiStats.avgResponseTime.toFixed(0)}ms`); + console.log('━'.repeat(50)); + console.groupEnd(); + } + } catch (error) { + console.error('Failed to collect initial metrics:', error); + } finally { + setIsLoading(false); + } + }, 2000); // 延迟 2 秒收集(确保 LCP/FCP 等指标已触发) + + // Cleanup + return () => { + clearTimeout(timeoutId); + }; + }, []); // 空依赖数组,只在挂载时执行一次 + + // ============================================================ + // 返回值 + // ============================================================ + + return { + isLoading, + metrics, + remeasure, + exportMetrics, + }; +}; + +// ============================================================ +// 辅助 Hook:标记骨架屏结束 +// ============================================================ + +/** + * 标记骨架屏结束的 Hook + * 用于在骨架屏消失时记录时间点 + * + * 使用示例: + * ```tsx + * const { markSkeletonEnd } = useSkeletonTiming(); + * + * useEffect(() => { + * if (!loading) { + * markSkeletonEnd(); + * } + * }, [loading, markSkeletonEnd]); + * ``` + */ +export const useSkeletonTiming = () => { + const skeletonStartRef = useRef(performance.now()); + const skeletonEndRef = useRef(null); + + const markSkeletonEnd = useCallback(() => { + if (!skeletonEndRef.current) { + skeletonEndRef.current = performance.now(); + const duration = skeletonEndRef.current - skeletonStartRef.current; + + if (process.env.NODE_ENV === 'development') { + console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`); + } + } + }, []); + + const getSkeletonDuration = useCallback((): number | null => { + if (skeletonEndRef.current) { + return skeletonEndRef.current - skeletonStartRef.current; + } + return null; + }, []); + + return { + markSkeletonEnd, + getSkeletonDuration, + }; +}; + +// ============================================================ +// 默认导出 +// ============================================================ + +export default useFirstScreenMetrics; From c9084ebb33b823507008f1c6c40980e44ea44049 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:22:18 +0800 Subject: [PATCH 15/17] =?UTF-8?q?feat:=20Socket.IO=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=EF=BC=88Mock=20=E6=A8=A1=E5=BC=8F=E4=B8=8B?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E7=94=9F=E4=BA=A7=E7=8E=AF=E5=A2=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.mock | 4 ++++ src/services/socketService.js | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.env.mock b/.env.mock index 6888e0d3..9f23c071 100644 --- a/.env.mock +++ b/.env.mock @@ -29,6 +29,10 @@ NODE_OPTIONS=--max_old_space_size=4096 # MSW 会在浏览器层拦截这些请求,不需要真实的后端地址 REACT_APP_API_URL= +# Socket.IO 连接地址(Mock 模式下连接生产环境) +# 注意:WebSocket 不被 MSW 拦截,可以独立配置 +REACT_APP_SOCKET_URL=https://valuefrontier.cn + # 启用 Mock 数据(核心配置) # 此配置会触发 src/index.js 中的 MSW 初始化 REACT_APP_ENABLE_MOCK=true diff --git a/src/services/socketService.js b/src/services/socketService.js index 2e8d22cb..874c4b96 100644 --- a/src/services/socketService.js +++ b/src/services/socketService.js @@ -7,7 +7,10 @@ import { io } from 'socket.io-client'; import { logger } from '../utils/logger'; import { getApiBase } from '../utils/apiConfig'; -const API_BASE_URL = getApiBase(); +// 优先使用 REACT_APP_SOCKET_URL(专门为 Socket.IO 配置) +// 如果未配置,则使用 getApiBase()(与 HTTP API 共用地址) +// Mock 模式下可以通过 .env.mock 配置 REACT_APP_SOCKET_URL=https://valuefrontier.cn 连接生产环境 +const API_BASE_URL = process.env.REACT_APP_SOCKET_URL || getApiBase(); class SocketService { constructor() { From baf4ca1ed46fbc219e1e3a322ae7bb7504a17e76 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 21 Nov 2025 18:45:13 +0800 Subject: [PATCH 16/17] =?UTF-8?q?feat:=20=20=E5=B1=8F=E8=94=BD=20STOMP=20W?= =?UTF-8?q?ebSocket=20=E9=94=99=E8=AF=AF=E6=97=A5=E5=BF=97=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E5=BD=B1=E5=93=8D=E5=8A=9F=E8=83=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BytedeskWidget.jsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/bytedesk-integration/components/BytedeskWidget.jsx b/src/bytedesk-integration/components/BytedeskWidget.jsx index 38a56855..2437a7ca 100644 --- a/src/bytedesk-integration/components/BytedeskWidget.jsx +++ b/src/bytedesk-integration/components/BytedeskWidget.jsx @@ -53,6 +53,21 @@ const BytedeskWidget = ({ widgetRef.current = bytedesk; console.log('[Bytedesk] Widget初始化成功'); + // ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能) + // Bytedesk SDK 内部的 /stomp WebSocket 连接失败不影响核心客服功能 + // SDK 会自动降级使用 HTTP 轮询 + const originalConsoleError = console.error; + console.error = function(...args) { + const errorMsg = args.join(' '); + // 忽略 /stomp 和 STOMP 相关错误 + if (errorMsg.includes('/stomp') || + errorMsg.includes('stomp onWebSocketError') || + (errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) { + return; // 不输出日志 + } + originalConsoleError.apply(console, args); + }; + if (onLoad) { onLoad(bytedesk); } From 982d8135e799c539ba881fd8e7f80c3b722f50dc Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 24 Nov 2025 16:38:33 +0800 Subject: [PATCH 17/17] =?UTF-8?q?feat:=20bug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Navbars/components/NavbarActions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Navbars/components/NavbarActions/index.js b/src/components/Navbars/components/NavbarActions/index.js index 25a72120..9032c3fc 100644 --- a/src/components/Navbars/components/NavbarActions/index.js +++ b/src/components/Navbars/components/NavbarActions/index.js @@ -4,7 +4,7 @@ import React, { memo } from 'react'; import { HStack, Spinner, IconButton, Box } from '@chakra-ui/react'; import { HamburgerIcon } from '@chakra-ui/icons'; -import ThemeToggleButton from '../ThemeToggleButton'; +// import ThemeToggleButton from '../ThemeToggleButton'; // ❌ 已删除 - 不再支持深色模式切换 import LoginButton from '../LoginButton'; import CalendarButton from '../CalendarButton'; import { WatchlistMenu, FollowingEventsMenu } from '../FeatureMenus';