Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref

This commit is contained in:
2025-11-24 16:39:47 +08:00
17 changed files with 2316 additions and 136 deletions

View File

@@ -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

View File

@@ -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 处理
},
},
}),
}),
},
},
};

View File

@@ -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",

View File

@@ -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]);

View File

@@ -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>
);
}

View File

@@ -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}
/>
</>

View File

@@ -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}

View File

@@ -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>

View 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;

View 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;

View File

@@ -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%',

View File

@@ -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
View 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;
}

View 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,
};

View 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,
};

View 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;

View File

@@ -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;