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 && (
+
+ )}
+
);
};
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';