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 会在浏览器层拦截这些请求,不需要真实的后端地址
|
||||
REACT_APP_API_URL=
|
||||
|
||||
# Socket.IO 连接地址(Mock 模式下连接生产环境)
|
||||
# 注意:WebSocket 不被 MSW 拦截,可以独立配置
|
||||
REACT_APP_SOCKET_URL=https://valuefrontier.cn
|
||||
|
||||
# 启用 Mock 数据(核心配置)
|
||||
# 此配置会触发 src/index.js 中的 MSW 初始化
|
||||
REACT_APP_ENABLE_MOCK=true
|
||||
|
||||
@@ -284,9 +284,19 @@ module.exports = {
|
||||
},
|
||||
|
||||
// 代理配置:将 /api 请求代理到后端服务器
|
||||
// 注意:Mock 模式下禁用 proxy,让 MSW 拦截请求
|
||||
...(isMockMode() ? {} : {
|
||||
proxy: {
|
||||
// 注意:Mock 模式下禁用 /api 和 /concept-api,让 MSW 拦截请求
|
||||
// 但 /bytedesk 始终启用(客服系统不走 Mock)
|
||||
proxy: {
|
||||
'/bytedesk': {
|
||||
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
|
||||
changeOrigin: true,
|
||||
secure: false, // 开发环境禁用 HTTPS 严格验证
|
||||
logLevel: 'debug',
|
||||
ws: true, // 支持 WebSocket
|
||||
// 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理
|
||||
},
|
||||
// Mock 模式下禁用其他代理
|
||||
...(isMockMode() ? {} : {
|
||||
'/api': {
|
||||
target: 'http://49.232.185.254:5001',
|
||||
changeOrigin: true,
|
||||
@@ -300,15 +310,7 @@ module.exports = {
|
||||
logLevel: 'debug',
|
||||
pathRewrite: { '^/concept-api': '' },
|
||||
},
|
||||
'/bytedesk': {
|
||||
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
|
||||
changeOrigin: true,
|
||||
secure: false, // 开发环境禁用 HTTPS 严格验证
|
||||
logLevel: 'debug',
|
||||
ws: true, // 支持 WebSocket
|
||||
// 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,9 +22,13 @@
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"@visx/scale": "^3.12.0",
|
||||
"@visx/text": "^3.12.0",
|
||||
"@tsparticles/react": "^3.0.0",
|
||||
"@tsparticles/slim": "^3.0.0",
|
||||
"@visx/visx": "^3.12.0",
|
||||
"@visx/wordcloud": "^3.12.0",
|
||||
"antd": "^5.27.4",
|
||||
"apexcharts": "^3.27.3",
|
||||
"axios": "^1.10.0",
|
||||
@@ -64,6 +68,9 @@
|
||||
"react-scroll": "^1.8.4",
|
||||
"react-scroll-into-view": "^2.1.3",
|
||||
"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",
|
||||
"recharts": "^3.1.2",
|
||||
"sass": "^1.49.9",
|
||||
|
||||
@@ -53,6 +53,21 @@ const BytedeskWidget = ({
|
||||
widgetRef.current = bytedesk;
|
||||
console.log('[Bytedesk] Widget初始化成功');
|
||||
|
||||
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能)
|
||||
// Bytedesk SDK 内部的 /stomp WebSocket 连接失败不影响核心客服功能
|
||||
// SDK 会自动降级使用 HTTP 轮询
|
||||
const originalConsoleError = console.error;
|
||||
console.error = function(...args) {
|
||||
const errorMsg = args.join(' ');
|
||||
// 忽略 /stomp 和 STOMP 相关错误
|
||||
if (errorMsg.includes('/stomp') ||
|
||||
errorMsg.includes('stomp onWebSocketError') ||
|
||||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
|
||||
return; // 不输出日志
|
||||
}
|
||||
originalConsoleError.apply(console, args);
|
||||
};
|
||||
|
||||
if (onLoad) {
|
||||
onLoad(bytedesk);
|
||||
}
|
||||
@@ -78,26 +93,43 @@ const BytedeskWidget = ({
|
||||
document.body.appendChild(script);
|
||||
scriptRef.current = script;
|
||||
|
||||
// 清理函数
|
||||
// 清理函数 - 增强错误处理,防止 React 18 StrictMode 双重清理报错
|
||||
return () => {
|
||||
console.log('[Bytedesk] 清理Widget');
|
||||
|
||||
// 移除脚本
|
||||
if (scriptRef.current && document.body.contains(scriptRef.current)) {
|
||||
document.body.removeChild(scriptRef.current);
|
||||
try {
|
||||
if (scriptRef.current && scriptRef.current.parentNode) {
|
||||
scriptRef.current.parentNode.removeChild(scriptRef.current);
|
||||
}
|
||||
scriptRef.current = null;
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 移除脚本失败(可能已被移除):', error.message);
|
||||
}
|
||||
|
||||
// 移除Widget DOM元素
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
if (el && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
try {
|
||||
if (el && el.parentNode && el.parentNode.contains(el)) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略单个元素移除失败(可能已被移除)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 清理Widget DOM元素失败:', error.message);
|
||||
}
|
||||
|
||||
// 清理全局对象
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
try {
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 清理全局对象失败:', error.message);
|
||||
}
|
||||
};
|
||||
}, [config, autoLoad, onLoad, onError]);
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
// src/components/Auth/AuthModalManager.js
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useBreakpointValue
|
||||
} from '@chakra-ui/react';
|
||||
import { Modal } from 'antd';
|
||||
import { useBreakpointValue } from '@chakra-ui/react';
|
||||
import { useAuthModal } from '../../hooks/useAuthModal';
|
||||
import AuthFormContent from './AuthFormContent';
|
||||
import { trackEventAsync } from '@lib/posthog';
|
||||
@@ -44,85 +38,43 @@ export default function AuthModalManager() {
|
||||
}
|
||||
}, [isAuthModalOpen]);
|
||||
|
||||
// 响应式尺寸配置
|
||||
const modalSize = useBreakpointValue({
|
||||
base: "md", // 移动端:md(不占满全屏)
|
||||
sm: "md", // 小屏:md
|
||||
md: "lg", // 中屏:lg
|
||||
lg: "xl" // 大屏:xl(更紧凑)
|
||||
});
|
||||
|
||||
// 响应式宽度配置
|
||||
const modalMaxW = useBreakpointValue({
|
||||
base: "90%", // 移动端:屏幕宽度的90%
|
||||
sm: "90%", // 小屏:90%
|
||||
md: "700px", // 中屏:固定700px
|
||||
lg: "700px" // 大屏:固定700px
|
||||
});
|
||||
|
||||
// 响应式水平边距
|
||||
const modalMx = useBreakpointValue({
|
||||
base: 4, // 移动端:左右各16px边距
|
||||
md: "auto" // 桌面端:自动居中
|
||||
});
|
||||
|
||||
// 响应式垂直边距
|
||||
const modalMy = useBreakpointValue({
|
||||
base: 8, // 移动端:上下各32px边距
|
||||
md: 8 // 桌面端:上下各32px边距
|
||||
});
|
||||
|
||||
// 条件渲染:只在打开时才渲染 Modal,避免创建不必要的 Portal
|
||||
if (!isAuthModalOpen) {
|
||||
return null;
|
||||
}
|
||||
// 响应式宽度配置(Ant Design Modal 使用数字或字符串)
|
||||
const modalMaxW = useBreakpointValue(
|
||||
{
|
||||
base: "90%", // 移动端:屏幕宽度的90%
|
||||
sm: "90%", // 小屏:90%
|
||||
md: "700px", // 中屏:固定700px
|
||||
lg: "700px" // 大屏:固定700px
|
||||
},
|
||||
{ fallback: "700px", ssr: false }
|
||||
);
|
||||
|
||||
// ✅ 使用 Ant Design Modal,完全避开 Chakra UI Portal 的 AnimatePresence 问题
|
||||
// Ant Design Modal 不使用 Framer Motion,不会有 React 18 并发渲染的 insertBefore 错误
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isAuthModalOpen}
|
||||
onClose={closeModal}
|
||||
size={modalSize}
|
||||
isCentered
|
||||
closeOnOverlayClick={false} // 防止误点击背景关闭
|
||||
closeOnEsc={true} // 允许ESC键关闭
|
||||
scrollBehavior="inside" // 内容滚动
|
||||
zIndex={999} // 低于导航栏(1000),不覆盖导航
|
||||
open={isAuthModalOpen}
|
||||
onCancel={closeModal}
|
||||
footer={null}
|
||||
width={modalMaxW}
|
||||
centered
|
||||
destroyOnHidden={true}
|
||||
maskClosable={false}
|
||||
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)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 半透明背景 + 模糊效果 */}
|
||||
<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>
|
||||
<AuthFormContent />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/GlobalComponents.js
|
||||
// 集中管理应用的全局组件
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -75,6 +75,9 @@ function ConnectionStatusBarWrapper() {
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
|
||||
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
|
||||
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Socket 连接状态条 */}
|
||||
@@ -89,9 +92,9 @@ export function GlobalComponents() {
|
||||
{/* 通知容器 */}
|
||||
<NotificationContainer />
|
||||
|
||||
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
|
||||
{/* Bytedesk在线客服 - 使用缓存的配置对象 */}
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
config={bytedeskConfigMemo}
|
||||
autoLoad={true}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -167,19 +167,8 @@ export default function HomeNavbar() {
|
||||
<BrandLogo />
|
||||
|
||||
{/* 中间导航区域 - 响应式 (Phase 4 优化) */}
|
||||
{isMobile ? (
|
||||
// 移动端:汉堡菜单
|
||||
<IconButton
|
||||
icon={<HamburgerIcon />}
|
||||
variant="ghost"
|
||||
onClick={onOpen}
|
||||
aria-label="Open menu"
|
||||
/>
|
||||
) : isTablet ? (
|
||||
// 中屏(平板):"更多"下拉菜单
|
||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||
) : (
|
||||
// 大屏(桌面):完整导航菜单
|
||||
{isDesktop && (
|
||||
// 桌面端:完整导航菜单(移动端和平板端的汉堡菜单已移至右侧)
|
||||
<DesktopNav isAuthenticated={isAuthenticated} user={user} />
|
||||
)}
|
||||
|
||||
@@ -189,6 +178,9 @@ export default function HomeNavbar() {
|
||||
isAuthenticated={isAuthenticated}
|
||||
user={user}
|
||||
isDesktop={isDesktop}
|
||||
isTablet={isTablet}
|
||||
isMobile={isMobile}
|
||||
onMenuOpen={onOpen}
|
||||
handleLogout={handleLogout}
|
||||
watchlistQuotes={watchlistQuotes}
|
||||
followingEvents={followingEvents}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
// Navbar 右侧功能区组件
|
||||
|
||||
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 CalendarButton from '../CalendarButton';
|
||||
import { WatchlistMenu, FollowingEventsMenu } from '../FeatureMenus';
|
||||
import { DesktopUserMenu, TabletUserMenu } from '../UserMenu';
|
||||
import { PersonalCenterMenu } from '../Navigation';
|
||||
import { PersonalCenterMenu, MoreMenu } from '../Navigation';
|
||||
|
||||
/**
|
||||
* Navbar 右侧功能区组件
|
||||
@@ -18,6 +20,9 @@ import { PersonalCenterMenu } from '../Navigation';
|
||||
* @param {boolean} props.isAuthenticated - 是否已登录
|
||||
* @param {Object} props.user - 用户对象
|
||||
* @param {boolean} props.isDesktop - 是否为桌面端
|
||||
* @param {boolean} props.isTablet - 是否为平板端
|
||||
* @param {boolean} props.isMobile - 是否为移动端
|
||||
* @param {Function} props.onMenuOpen - 打开移动端抽屉菜单的回调
|
||||
* @param {Function} props.handleLogout - 登出回调
|
||||
* @param {Array} props.watchlistQuotes - 自选股数据(用于 TabletUserMenu)
|
||||
* @param {Array} props.followingEvents - 关注事件数据(用于 TabletUserMenu)
|
||||
@@ -27,6 +32,9 @@ const NavbarActions = memo(({
|
||||
isAuthenticated,
|
||||
user,
|
||||
isDesktop,
|
||||
isTablet,
|
||||
isMobile,
|
||||
onMenuOpen,
|
||||
handleLogout,
|
||||
watchlistQuotes,
|
||||
followingEvents
|
||||
@@ -60,13 +68,26 @@ const NavbarActions = memo(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 个人中心下拉菜单 - 仅大屏显示 */}
|
||||
{isDesktop && (
|
||||
{/* 头像右侧的菜单 - 响应式(互斥逻辑,确保只渲染一个) */}
|
||||
{isDesktop ? (
|
||||
// 桌面端:个人中心下拉菜单
|
||||
<PersonalCenterMenu user={user} handleLogout={handleLogout} />
|
||||
) : isTablet ? (
|
||||
// 平板端:MoreMenu 下拉菜单
|
||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||
) : (
|
||||
// 移动端:汉堡菜单(打开抽屉)
|
||||
<IconButton
|
||||
icon={<HamburgerIcon />}
|
||||
variant="ghost"
|
||||
onClick={onMenuOpen}
|
||||
aria-label="打开菜单"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
// 未登录状态 - 单一按钮
|
||||
// 未登录状态 - 仅显示登录按钮
|
||||
<LoginButton />
|
||||
)}
|
||||
</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',
|
||||
NOT_FOUND_404: '404 Not Found',
|
||||
|
||||
// Performance
|
||||
// Performance - Web Vitals
|
||||
PAGE_LOAD_TIME: 'Page Load Time',
|
||||
API_RESPONSE_TIME: 'API Response Time',
|
||||
WEB_VITALS_LCP: 'Web Vitals - LCP', // Largest Contentful Paint
|
||||
WEB_VITALS_FCP: 'Web Vitals - FCP', // First Contentful Paint
|
||||
WEB_VITALS_CLS: 'Web Vitals - CLS', // Cumulative Layout Shift
|
||||
WEB_VITALS_FID: 'Web Vitals - FID', // First Input Delay
|
||||
WEB_VITALS_TTFB: 'Web Vitals - TTFB', // Time to First Byte
|
||||
|
||||
// Performance - First Screen
|
||||
FIRST_SCREEN_INTERACTIVE: 'First Screen Interactive', // 首屏可交互时间
|
||||
RESOURCE_LOAD_COMPLETE: 'Resource Load Complete', // 资源加载完成
|
||||
SKELETON_DISPLAYED: 'Skeleton Displayed', // 骨架屏展示
|
||||
|
||||
// Scroll depth
|
||||
SCROLL_DEPTH_25: 'Scroll Depth 25%',
|
||||
|
||||
@@ -7,7 +7,10 @@ import { io } from 'socket.io-client';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
// 优先使用 REACT_APP_SOCKET_URL(专门为 Socket.IO 配置)
|
||||
// 如果未配置,则使用 getApiBase()(与 HTTP API 共用地址)
|
||||
// Mock 模式下可以通过 .env.mock 配置 REACT_APP_SOCKET_URL=https://valuefrontier.cn 连接生产环境
|
||||
const API_BASE_URL = process.env.REACT_APP_SOCKET_URL || getApiBase();
|
||||
|
||||
class SocketService {
|
||||
constructor() {
|
||||
|
||||
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 {
|
||||
Box,
|
||||
Card,
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
import { getFormattedTextProps } from '../../../utils/textUtils';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import RiskDisclaimer from '../../../components/RiskDisclaimer';
|
||||
import './WordCloud.css';
|
||||
import {
|
||||
BarChart, Bar,
|
||||
PieChart, Pie, Cell,
|
||||
@@ -51,6 +50,10 @@ import {
|
||||
Treemap,
|
||||
Area, AreaChart,
|
||||
} 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 'echarts-wordcloud';
|
||||
// 颜色配置
|
||||
@@ -59,8 +62,101 @@ const CHART_COLORS = [
|
||||
'#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) {
|
||||
return (
|
||||
<Center h="400px">
|
||||
@@ -97,8 +193,7 @@ const WordCloud = ({ data }) => {
|
||||
fontFamily: 'Microsoft YaHei, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: function () {
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
return WORDCLOUD_COLORS[Math.floor(Math.random() * WORDCLOUD_COLORS.length)];
|
||||
}
|
||||
},
|
||||
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 }) => {
|
||||
if (!data) return null;
|
||||
|
||||
Reference in New Issue
Block a user