Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref
This commit is contained in:
@@ -29,6 +29,10 @@ NODE_OPTIONS=--max_old_space_size=4096
|
|||||||
# MSW 会在浏览器层拦截这些请求,不需要真实的后端地址
|
# MSW 会在浏览器层拦截这些请求,不需要真实的后端地址
|
||||||
REACT_APP_API_URL=
|
REACT_APP_API_URL=
|
||||||
|
|
||||||
|
# Socket.IO 连接地址(Mock 模式下连接生产环境)
|
||||||
|
# 注意:WebSocket 不被 MSW 拦截,可以独立配置
|
||||||
|
REACT_APP_SOCKET_URL=https://valuefrontier.cn
|
||||||
|
|
||||||
# 启用 Mock 数据(核心配置)
|
# 启用 Mock 数据(核心配置)
|
||||||
# 此配置会触发 src/index.js 中的 MSW 初始化
|
# 此配置会触发 src/index.js 中的 MSW 初始化
|
||||||
REACT_APP_ENABLE_MOCK=true
|
REACT_APP_ENABLE_MOCK=true
|
||||||
|
|||||||
@@ -284,9 +284,19 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 代理配置:将 /api 请求代理到后端服务器
|
// 代理配置:将 /api 请求代理到后端服务器
|
||||||
// 注意:Mock 模式下禁用 proxy,让 MSW 拦截请求
|
// 注意:Mock 模式下禁用 /api 和 /concept-api,让 MSW 拦截请求
|
||||||
...(isMockMode() ? {} : {
|
// 但 /bytedesk 始终启用(客服系统不走 Mock)
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/bytedesk': {
|
||||||
|
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false, // 开发环境禁用 HTTPS 严格验证
|
||||||
|
logLevel: 'debug',
|
||||||
|
ws: true, // 支持 WebSocket
|
||||||
|
// 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理
|
||||||
|
},
|
||||||
|
// Mock 模式下禁用其他代理
|
||||||
|
...(isMockMode() ? {} : {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://49.232.185.254:5001',
|
target: 'http://49.232.185.254:5001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@@ -300,15 +310,7 @@ module.exports = {
|
|||||||
logLevel: 'debug',
|
logLevel: 'debug',
|
||||||
pathRewrite: { '^/concept-api': '' },
|
pathRewrite: { '^/concept-api': '' },
|
||||||
},
|
},
|
||||||
'/bytedesk': {
|
}),
|
||||||
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
|
},
|
||||||
changeOrigin: true,
|
|
||||||
secure: false, // 开发环境禁用 HTTPS 严格验证
|
|
||||||
logLevel: 'debug',
|
|
||||||
ws: true, // 支持 WebSocket
|
|
||||||
// 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,9 +22,13 @@
|
|||||||
"@splidejs/react-splide": "^0.7.12",
|
"@splidejs/react-splide": "^0.7.12",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
|
"@visx/responsive": "^3.12.0",
|
||||||
|
"@visx/scale": "^3.12.0",
|
||||||
|
"@visx/text": "^3.12.0",
|
||||||
"@tsparticles/react": "^3.0.0",
|
"@tsparticles/react": "^3.0.0",
|
||||||
"@tsparticles/slim": "^3.0.0",
|
"@tsparticles/slim": "^3.0.0",
|
||||||
"@visx/visx": "^3.12.0",
|
"@visx/visx": "^3.12.0",
|
||||||
|
"@visx/wordcloud": "^3.12.0",
|
||||||
"antd": "^5.27.4",
|
"antd": "^5.27.4",
|
||||||
"apexcharts": "^3.27.3",
|
"apexcharts": "^3.27.3",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
@@ -64,6 +68,9 @@
|
|||||||
"react-scroll": "^1.8.4",
|
"react-scroll": "^1.8.4",
|
||||||
"react-scroll-into-view": "^2.1.3",
|
"react-scroll-into-view": "^2.1.3",
|
||||||
"react-table": "^7.7.0",
|
"react-table": "^7.7.0",
|
||||||
|
"react-tagsinput": "3.19.0",
|
||||||
|
"react-to-print": "^2.13.0",
|
||||||
|
"react-tsparticles": "^2.12.2",
|
||||||
"react-to-print": "^3.0.3",
|
"react-to-print": "^3.0.3",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.1.2",
|
||||||
"sass": "^1.49.9",
|
"sass": "^1.49.9",
|
||||||
|
|||||||
@@ -53,6 +53,21 @@ const BytedeskWidget = ({
|
|||||||
widgetRef.current = bytedesk;
|
widgetRef.current = bytedesk;
|
||||||
console.log('[Bytedesk] Widget初始化成功');
|
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) {
|
if (onLoad) {
|
||||||
onLoad(bytedesk);
|
onLoad(bytedesk);
|
||||||
}
|
}
|
||||||
@@ -78,26 +93,43 @@ const BytedeskWidget = ({
|
|||||||
document.body.appendChild(script);
|
document.body.appendChild(script);
|
||||||
scriptRef.current = script;
|
scriptRef.current = script;
|
||||||
|
|
||||||
// 清理函数
|
// 清理函数 - 增强错误处理,防止 React 18 StrictMode 双重清理报错
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[Bytedesk] 清理Widget');
|
console.log('[Bytedesk] 清理Widget');
|
||||||
|
|
||||||
// 移除脚本
|
// 移除脚本
|
||||||
if (scriptRef.current && document.body.contains(scriptRef.current)) {
|
try {
|
||||||
document.body.removeChild(scriptRef.current);
|
if (scriptRef.current && scriptRef.current.parentNode) {
|
||||||
|
scriptRef.current.parentNode.removeChild(scriptRef.current);
|
||||||
|
}
|
||||||
|
scriptRef.current = null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Bytedesk] 移除脚本失败(可能已被移除):', error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除Widget DOM元素
|
// 移除Widget DOM元素
|
||||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
try {
|
||||||
widgetElements.forEach(el => {
|
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||||
if (el && el.parentNode) {
|
widgetElements.forEach(el => {
|
||||||
el.parentNode.removeChild(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) {
|
try {
|
||||||
delete window.BytedeskWeb;
|
if (window.BytedeskWeb) {
|
||||||
|
delete window.BytedeskWeb;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Bytedesk] 清理全局对象失败:', error.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [config, autoLoad, onLoad, onError]);
|
}, [config, autoLoad, onLoad, onError]);
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
// src/components/Auth/AuthModalManager.js
|
// src/components/Auth/AuthModalManager.js
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import {
|
import { Modal } from 'antd';
|
||||||
Modal,
|
import { useBreakpointValue } from '@chakra-ui/react';
|
||||||
ModalOverlay,
|
|
||||||
ModalContent,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
useBreakpointValue
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { useAuthModal } from '../../hooks/useAuthModal';
|
import { useAuthModal } from '../../hooks/useAuthModal';
|
||||||
import AuthFormContent from './AuthFormContent';
|
import AuthFormContent from './AuthFormContent';
|
||||||
import { trackEventAsync } from '@lib/posthog';
|
import { trackEventAsync } from '@lib/posthog';
|
||||||
@@ -44,85 +38,43 @@ export default function AuthModalManager() {
|
|||||||
}
|
}
|
||||||
}, [isAuthModalOpen]);
|
}, [isAuthModalOpen]);
|
||||||
|
|
||||||
// 响应式尺寸配置
|
// 响应式宽度配置(Ant Design Modal 使用数字或字符串)
|
||||||
const modalSize = useBreakpointValue({
|
const modalMaxW = useBreakpointValue(
|
||||||
base: "md", // 移动端:md(不占满全屏)
|
{
|
||||||
sm: "md", // 小屏:md
|
base: "90%", // 移动端:屏幕宽度的90%
|
||||||
md: "lg", // 中屏:lg
|
sm: "90%", // 小屏:90%
|
||||||
lg: "xl" // 大屏:xl(更紧凑)
|
md: "700px", // 中屏:固定700px
|
||||||
});
|
lg: "700px" // 大屏:固定700px
|
||||||
|
},
|
||||||
// 响应式宽度配置
|
{ fallback: "700px", ssr: false }
|
||||||
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,完全避开 Chakra UI Portal 的 AnimatePresence 问题
|
||||||
|
// Ant Design Modal 不使用 Framer Motion,不会有 React 18 并发渲染的 insertBefore 错误
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isAuthModalOpen}
|
open={isAuthModalOpen}
|
||||||
onClose={closeModal}
|
onCancel={closeModal}
|
||||||
size={modalSize}
|
footer={null}
|
||||||
isCentered
|
width={modalMaxW}
|
||||||
closeOnOverlayClick={false} // 防止误点击背景关闭
|
centered
|
||||||
closeOnEsc={true} // 允许ESC键关闭
|
destroyOnHidden={true}
|
||||||
scrollBehavior="inside" // 内容滚动
|
maskClosable={false}
|
||||||
zIndex={999} // 低于导航栏(1000),不覆盖导航
|
keyboard={true}
|
||||||
|
zIndex={999}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: '24px',
|
||||||
|
maxHeight: 'calc(90vh - 120px)',
|
||||||
|
overflowY: 'auto'
|
||||||
|
},
|
||||||
|
mask: {
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* 半透明背景 + 模糊效果 */}
|
<AuthFormContent />
|
||||||
<ModalOverlay
|
|
||||||
bg="blackAlpha.700"
|
|
||||||
backdropFilter="blur(10px)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 弹窗内容容器 */}
|
|
||||||
<ModalContent
|
|
||||||
bg="white"
|
|
||||||
boxShadow="2xl"
|
|
||||||
borderRadius="2xl"
|
|
||||||
maxW={modalMaxW}
|
|
||||||
mx={modalMx}
|
|
||||||
my={modalMy}
|
|
||||||
position="relative"
|
|
||||||
>
|
|
||||||
{/* 关闭按钮 */}
|
|
||||||
<ModalCloseButton
|
|
||||||
position="absolute"
|
|
||||||
right={4}
|
|
||||||
top={4}
|
|
||||||
zIndex={9999}
|
|
||||||
color="gray.500"
|
|
||||||
bg="transparent"
|
|
||||||
_hover={{ bg: "gray.100" }}
|
|
||||||
borderRadius="full"
|
|
||||||
size="lg"
|
|
||||||
onClick={closeModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 弹窗主体内容 */}
|
|
||||||
<ModalBody p={6}>
|
|
||||||
<AuthFormContent />
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/components/GlobalComponents.js
|
// src/components/GlobalComponents.js
|
||||||
// 集中管理应用的全局组件
|
// 集中管理应用的全局组件
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
@@ -75,6 +75,9 @@ function ConnectionStatusBarWrapper() {
|
|||||||
export function GlobalComponents() {
|
export function GlobalComponents() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
|
||||||
|
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Socket 连接状态条 */}
|
{/* Socket 连接状态条 */}
|
||||||
@@ -89,9 +92,9 @@ export function GlobalComponents() {
|
|||||||
{/* 通知容器 */}
|
{/* 通知容器 */}
|
||||||
<NotificationContainer />
|
<NotificationContainer />
|
||||||
|
|
||||||
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
|
{/* Bytedesk在线客服 - 使用缓存的配置对象 */}
|
||||||
<BytedeskWidget
|
<BytedeskWidget
|
||||||
config={getBytedeskConfig()}
|
config={bytedeskConfigMemo}
|
||||||
autoLoad={true}
|
autoLoad={true}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -167,19 +167,8 @@ export default function HomeNavbar() {
|
|||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
|
|
||||||
{/* 中间导航区域 - 响应式 (Phase 4 优化) */}
|
{/* 中间导航区域 - 响应式 (Phase 4 优化) */}
|
||||||
{isMobile ? (
|
{isDesktop && (
|
||||||
// 移动端:汉堡菜单
|
// 桌面端:完整导航菜单(移动端和平板端的汉堡菜单已移至右侧)
|
||||||
<IconButton
|
|
||||||
icon={<HamburgerIcon />}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onOpen}
|
|
||||||
aria-label="Open menu"
|
|
||||||
/>
|
|
||||||
) : isTablet ? (
|
|
||||||
// 中屏(平板):"更多"下拉菜单
|
|
||||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
|
||||||
) : (
|
|
||||||
// 大屏(桌面):完整导航菜单
|
|
||||||
<DesktopNav isAuthenticated={isAuthenticated} user={user} />
|
<DesktopNav isAuthenticated={isAuthenticated} user={user} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -189,6 +178,9 @@ export default function HomeNavbar() {
|
|||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
user={user}
|
user={user}
|
||||||
isDesktop={isDesktop}
|
isDesktop={isDesktop}
|
||||||
|
isTablet={isTablet}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onMenuOpen={onOpen}
|
||||||
handleLogout={handleLogout}
|
handleLogout={handleLogout}
|
||||||
watchlistQuotes={watchlistQuotes}
|
watchlistQuotes={watchlistQuotes}
|
||||||
followingEvents={followingEvents}
|
followingEvents={followingEvents}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
// Navbar 右侧功能区组件
|
// Navbar 右侧功能区组件
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { HStack, Spinner } 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 LoginButton from '../LoginButton';
|
||||||
import CalendarButton from '../CalendarButton';
|
import CalendarButton from '../CalendarButton';
|
||||||
import { WatchlistMenu, FollowingEventsMenu } from '../FeatureMenus';
|
import { WatchlistMenu, FollowingEventsMenu } from '../FeatureMenus';
|
||||||
import { DesktopUserMenu, TabletUserMenu } from '../UserMenu';
|
import { DesktopUserMenu, TabletUserMenu } from '../UserMenu';
|
||||||
import { PersonalCenterMenu } from '../Navigation';
|
import { PersonalCenterMenu, MoreMenu } from '../Navigation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navbar 右侧功能区组件
|
* Navbar 右侧功能区组件
|
||||||
@@ -18,6 +20,9 @@ import { PersonalCenterMenu } from '../Navigation';
|
|||||||
* @param {boolean} props.isAuthenticated - 是否已登录
|
* @param {boolean} props.isAuthenticated - 是否已登录
|
||||||
* @param {Object} props.user - 用户对象
|
* @param {Object} props.user - 用户对象
|
||||||
* @param {boolean} props.isDesktop - 是否为桌面端
|
* @param {boolean} props.isDesktop - 是否为桌面端
|
||||||
|
* @param {boolean} props.isTablet - 是否为平板端
|
||||||
|
* @param {boolean} props.isMobile - 是否为移动端
|
||||||
|
* @param {Function} props.onMenuOpen - 打开移动端抽屉菜单的回调
|
||||||
* @param {Function} props.handleLogout - 登出回调
|
* @param {Function} props.handleLogout - 登出回调
|
||||||
* @param {Array} props.watchlistQuotes - 自选股数据(用于 TabletUserMenu)
|
* @param {Array} props.watchlistQuotes - 自选股数据(用于 TabletUserMenu)
|
||||||
* @param {Array} props.followingEvents - 关注事件数据(用于 TabletUserMenu)
|
* @param {Array} props.followingEvents - 关注事件数据(用于 TabletUserMenu)
|
||||||
@@ -27,6 +32,9 @@ const NavbarActions = memo(({
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
user,
|
user,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
|
isTablet,
|
||||||
|
isMobile,
|
||||||
|
onMenuOpen,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
watchlistQuotes,
|
watchlistQuotes,
|
||||||
followingEvents
|
followingEvents
|
||||||
@@ -60,13 +68,26 @@ const NavbarActions = memo(({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 个人中心下拉菜单 - 仅大屏显示 */}
|
{/* 头像右侧的菜单 - 响应式(互斥逻辑,确保只渲染一个) */}
|
||||||
{isDesktop && (
|
{isDesktop ? (
|
||||||
|
// 桌面端:个人中心下拉菜单
|
||||||
<PersonalCenterMenu user={user} handleLogout={handleLogout} />
|
<PersonalCenterMenu user={user} handleLogout={handleLogout} />
|
||||||
|
) : isTablet ? (
|
||||||
|
// 平板端:MoreMenu 下拉菜单
|
||||||
|
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||||
|
) : (
|
||||||
|
// 移动端:汉堡菜单(打开抽屉)
|
||||||
|
<IconButton
|
||||||
|
icon={<HamburgerIcon />}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onMenuOpen}
|
||||||
|
aria-label="打开菜单"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
) : (
|
) : (
|
||||||
// 未登录状态 - 单一按钮
|
// 未登录状态 - 仅显示登录按钮
|
||||||
<LoginButton />
|
<LoginButton />
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
306
src/constants/performanceThresholds.js
Normal file
306
src/constants/performanceThresholds.js
Normal file
@@ -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;
|
||||||
291
src/hooks/useFirstScreenMetrics.ts
Normal file
291
src/hooks/useFirstScreenMetrics.ts
Normal file
@@ -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<FirstScreenMetrics | null>(null);
|
||||||
|
|
||||||
|
// 使用 ref 记录页面加载开始时间
|
||||||
|
const pageLoadStartRef = useRef<number>(performance.now());
|
||||||
|
const skeletonStartRef = useRef<number>(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<number>(performance.now());
|
||||||
|
const skeletonEndRef = useRef<number | null>(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;
|
||||||
@@ -225,9 +225,19 @@ export const SPECIAL_EVENTS = {
|
|||||||
API_ERROR: 'API Error',
|
API_ERROR: 'API Error',
|
||||||
NOT_FOUND_404: '404 Not Found',
|
NOT_FOUND_404: '404 Not Found',
|
||||||
|
|
||||||
// Performance
|
// Performance - Web Vitals
|
||||||
PAGE_LOAD_TIME: 'Page Load Time',
|
PAGE_LOAD_TIME: 'Page Load Time',
|
||||||
API_RESPONSE_TIME: 'API Response 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
|
||||||
SCROLL_DEPTH_25: 'Scroll Depth 25%',
|
SCROLL_DEPTH_25: 'Scroll Depth 25%',
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { io } from 'socket.io-client';
|
|||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { getApiBase } from '../utils/apiConfig';
|
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 {
|
class SocketService {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
349
src/types/metrics.ts
Normal file
349
src/types/metrics.ts
Normal file
@@ -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<WebVitalsMetrics>;
|
||||||
|
/** 资源加载统计 */
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<WebVitalsMetrics>;
|
||||||
|
/** 资源统计 */
|
||||||
|
resources: ResourceStats;
|
||||||
|
/** API 统计 */
|
||||||
|
api: ApiRequestStats;
|
||||||
|
/** 首屏时间 */
|
||||||
|
firstScreen: {
|
||||||
|
tti: number;
|
||||||
|
skeletonDuration: number;
|
||||||
|
};
|
||||||
|
/** 最后更新时间 */
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
435
src/utils/performance/resourceMonitor.ts
Normal file
435
src/utils/performance/resourceMonitor.ts
Normal file
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 全局状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
467
src/utils/performance/webVitals.ts
Normal file
467
src/utils/performance/webVitals.ts
Normal file
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerformanceMetric {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
rating: MetricRating;
|
||||||
|
entries: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 全局状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
let metricsCache: Map<string, WebVitalMetric> = 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<string, WebVitalMetric> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
194
src/views/Home/components/HomePageSkeleton.tsx
Normal file
194
src/views/Home/components/HomePageSkeleton.tsx
Normal file
@@ -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 (
|
||||||
|
<Box
|
||||||
|
bg={bg}
|
||||||
|
borderRadius="xl"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
p={isFeatured ? 8 : 6}
|
||||||
|
h={isFeatured ? '350px' : '280px'}
|
||||||
|
boxShadow={isFeatured ? 'xl' : 'md'}
|
||||||
|
position="relative"
|
||||||
|
>
|
||||||
|
<VStack align="start" spacing={4} h="full">
|
||||||
|
{/* 图标骨架 */}
|
||||||
|
<Skeleton
|
||||||
|
height={isFeatured ? '60px' : '48px'}
|
||||||
|
width={isFeatured ? '60px' : '48px'}
|
||||||
|
borderRadius="lg"
|
||||||
|
startColor={isFeatured ? 'blue.100' : 'gray.100'}
|
||||||
|
endColor={isFeatured ? 'blue.200' : 'gray.200'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 标题骨架 */}
|
||||||
|
<Skeleton height="28px" width="70%" borderRadius="md" />
|
||||||
|
|
||||||
|
{/* 描述骨架 */}
|
||||||
|
<SkeletonText
|
||||||
|
mt="2"
|
||||||
|
noOfLines={isFeatured ? 4 : 3}
|
||||||
|
spacing="3"
|
||||||
|
skeletonHeight="2"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 按钮骨架 */}
|
||||||
|
<Skeleton
|
||||||
|
height="40px"
|
||||||
|
width={isFeatured ? '140px' : '100px'}
|
||||||
|
borderRadius="md"
|
||||||
|
mt="auto"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Featured 徽章骨架 */}
|
||||||
|
{isFeatured && (
|
||||||
|
<Skeleton
|
||||||
|
position="absolute"
|
||||||
|
top="4"
|
||||||
|
right="4"
|
||||||
|
height="24px"
|
||||||
|
width="80px"
|
||||||
|
borderRadius="full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主骨架组件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const HomePageSkeleton: React.FC<HomePageSkeletonProps> = ({
|
||||||
|
isAnimated = true,
|
||||||
|
speed = 0.8,
|
||||||
|
}) => {
|
||||||
|
const containerBg = useColorModeValue('gray.50', 'gray.900');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
w="full"
|
||||||
|
minH="100vh"
|
||||||
|
bg={containerBg}
|
||||||
|
pt={{ base: '120px', md: '140px' }}
|
||||||
|
pb={{ base: '60px', md: '80px' }}
|
||||||
|
>
|
||||||
|
<Container maxW="container.xl">
|
||||||
|
<VStack spacing={{ base: 8, md: 12 }} align="stretch">
|
||||||
|
{/* 顶部标题区域骨架 */}
|
||||||
|
<VStack spacing={4} textAlign="center">
|
||||||
|
{/* 主标题 */}
|
||||||
|
<Skeleton
|
||||||
|
height={{ base: '40px', md: '56px' }}
|
||||||
|
width={{ base: '80%', md: '500px' }}
|
||||||
|
borderRadius="md"
|
||||||
|
speed={speed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 副标题 */}
|
||||||
|
<Skeleton
|
||||||
|
height={{ base: '20px', md: '24px' }}
|
||||||
|
width={{ base: '90%', md: '600px' }}
|
||||||
|
borderRadius="md"
|
||||||
|
speed={speed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* CTA 按钮 */}
|
||||||
|
<HStack spacing={4} mt={4}>
|
||||||
|
<Skeleton
|
||||||
|
height="48px"
|
||||||
|
width="140px"
|
||||||
|
borderRadius="lg"
|
||||||
|
speed={speed}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="48px"
|
||||||
|
width="140px"
|
||||||
|
borderRadius="lg"
|
||||||
|
speed={speed}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* 功能卡片网格骨架 */}
|
||||||
|
<SimpleGrid
|
||||||
|
columns={{ base: 1, md: 2, lg: 3 }}
|
||||||
|
spacing={{ base: 6, md: 8 }}
|
||||||
|
mt={8}
|
||||||
|
>
|
||||||
|
{/* 第一张卡片 - Featured (新闻中心) */}
|
||||||
|
<Box gridColumn={{ base: 'span 1', lg: 'span 2' }}>
|
||||||
|
<FeatureCardSkeleton isFeatured />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 其余 5 张卡片 */}
|
||||||
|
{[1, 2, 3, 4, 5].map((index) => (
|
||||||
|
<FeatureCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 底部装饰元素骨架 */}
|
||||||
|
<HStack justify="center" spacing={8} mt={12}>
|
||||||
|
{[1, 2, 3].map((index) => (
|
||||||
|
<VStack key={index} spacing={2} align="center">
|
||||||
|
<Skeleton
|
||||||
|
height="40px"
|
||||||
|
width="40px"
|
||||||
|
borderRadius="full"
|
||||||
|
speed={speed}
|
||||||
|
/>
|
||||||
|
<Skeleton height="16px" width="60px" borderRadius="md" speed={speed} />
|
||||||
|
</VStack>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 默认导出
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default HomePageSkeleton;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
import { getFormattedTextProps } from '../../../utils/textUtils';
|
import { getFormattedTextProps } from '../../../utils/textUtils';
|
||||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||||
import RiskDisclaimer from '../../../components/RiskDisclaimer';
|
import RiskDisclaimer from '../../../components/RiskDisclaimer';
|
||||||
import './WordCloud.css';
|
|
||||||
import {
|
import {
|
||||||
BarChart, Bar,
|
BarChart, Bar,
|
||||||
PieChart, Pie, Cell,
|
PieChart, Pie, Cell,
|
||||||
@@ -51,6 +50,10 @@ import {
|
|||||||
Treemap,
|
Treemap,
|
||||||
Area, AreaChart,
|
Area, AreaChart,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
// 词云库 - 支持两种实现
|
||||||
|
import { Wordcloud } from '@visx/wordcloud';
|
||||||
|
import { scaleLog } from '@visx/scale';
|
||||||
|
import { Text as VisxText } from '@visx/text';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import 'echarts-wordcloud';
|
import 'echarts-wordcloud';
|
||||||
// 颜色配置
|
// 颜色配置
|
||||||
@@ -59,8 +62,101 @@ const CHART_COLORS = [
|
|||||||
'#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB'
|
'#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB'
|
||||||
];
|
];
|
||||||
|
|
||||||
// 词云图组件(使用 ECharts Wordcloud)
|
// 词云颜色常量
|
||||||
const WordCloud = ({ data }) => {
|
const WORDCLOUD_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
|
||||||
|
|
||||||
|
// ==================== 词云组件实现 1: @visx/wordcloud ====================
|
||||||
|
// 使用 SVG 渲染,React 18 原生支持,配置灵活
|
||||||
|
const VisxWordCloud = ({ 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 (
|
||||||
|
<Center h="400px">
|
||||||
|
<VStack>
|
||||||
|
<Text color="gray.500">暂无词云数据</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = data.slice(0, 100).map(item => ({
|
||||||
|
name: item.name || item.text,
|
||||||
|
value: item.value || item.count || 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 计算字体大小比例
|
||||||
|
const fontScale = scaleLog({
|
||||||
|
domain: [
|
||||||
|
Math.min(...words.map(w => w.value)),
|
||||||
|
Math.max(...words.map(w => w.value))
|
||||||
|
],
|
||||||
|
range: [16, 80],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontSizeSetter = (datum) => fontScale(datum.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={containerRef} h="400px" w="100%">
|
||||||
|
{dimensions.width > 0 && (
|
||||||
|
<svg width={dimensions.width} height={dimensions.height}>
|
||||||
|
<Wordcloud
|
||||||
|
words={words}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
fontSize={fontSizeSetter}
|
||||||
|
font="Microsoft YaHei, sans-serif"
|
||||||
|
padding={3}
|
||||||
|
spiral="archimedean"
|
||||||
|
rotate={0}
|
||||||
|
random={() => 0.5}
|
||||||
|
>
|
||||||
|
{(cloudWords) =>
|
||||||
|
cloudWords.map((w, i) => (
|
||||||
|
<VisxText
|
||||||
|
key={w.text}
|
||||||
|
fill={WORDCLOUD_COLORS[i % WORDCLOUD_COLORS.length]}
|
||||||
|
textAnchor="middle"
|
||||||
|
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
|
||||||
|
fontSize={w.size}
|
||||||
|
fontFamily={w.font}
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{w.text}
|
||||||
|
</VisxText>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Wordcloud>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 词云组件实现 2: ECharts Wordcloud ====================
|
||||||
|
// 使用 Canvas 渲染,内置交互效果(tooltip、emphasis),配置简单
|
||||||
|
const EChartsWordCloud = ({ data }) => {
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Center h="400px">
|
<Center h="400px">
|
||||||
@@ -97,8 +193,7 @@ const WordCloud = ({ data }) => {
|
|||||||
fontFamily: 'Microsoft YaHei, sans-serif',
|
fontFamily: 'Microsoft YaHei, sans-serif',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: function () {
|
color: function () {
|
||||||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
|
return WORDCLOUD_COLORS[Math.floor(Math.random() * WORDCLOUD_COLORS.length)];
|
||||||
return colors[Math.floor(Math.random() * colors.length)];
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emphasis: {
|
emphasis: {
|
||||||
@@ -121,6 +216,23 @@ const WordCloud = ({ data }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 词云组件包装器 ====================
|
||||||
|
// 统一接口,支持切换两种实现方式
|
||||||
|
const WordCloud = ({ data, engine = 'echarts' }) => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center h="400px">
|
||||||
|
<VStack>
|
||||||
|
<Text color="gray.500">暂无词云数据</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 engine 参数选择实现方式
|
||||||
|
return engine === 'visx' ? <VisxWordCloud data={data} /> : <EChartsWordCloud data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
// 板块热力图组件
|
// 板块热力图组件
|
||||||
const SectorHeatMap = ({ data }) => {
|
const SectorHeatMap = ({ data }) => {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user