Compare commits

...

58 Commits

Author SHA1 Message Date
zdl
0be357a1c5 feat: 修改找不到文件的记录 2025-11-25 17:15:30 +08:00
zdl
9f907b3cba 移除 MidjourneyHeroSection 组件及其所有依赖
1: 删除组件文件 MidjourneyHeroSection.js
    2: 修改 HomePage.js
    3: 卸载相关 npm 包  @tsparticles/react 和 @tsparticles/slim
2025-11-25 17:04:30 +08:00
zdl
bb878c5346 feat: deviceSlice添加 2025-11-25 17:04:30 +08:00
zdl
1bc3241596 feat: 创建 Redux Device Slice(简化版)
注册到 Redux Store
2025-11-25 17:04:10 +08:00
zdl
cb46971e0e feat:1️⃣ 增强 performanceMonitor.ts
-  新增 measure(name, startMark, endMark) 方法(支持命名测量)
  -  新增 getMarks() - 获取所有性能标记
  -  新增 getMeasures() - 获取所有测量结果
  -  新增 getReport() - 返回完整 JSON 报告
  -  新增 exportJSON() - 导出 JSON 文件
  -  新增 reportToPostHog() - 上报到 PostHog
  -  新增全局 API window.__PERFORMANCE__(仅开发环境)
  -  彩色控制台使用说明

  2️⃣ 添加 PostHog 性能上报

  -  在 posthog.js 中新增 reportPerformanceMetrics() 函数
  -  上报所有关键性能指标(网络、渲染、React)
  -  自动计算性能评分(0-100)
  -  包含浏览器和设备信息
2025-11-25 17:04:10 +08:00
6679d99cf9 update pay function 2025-11-25 16:49:44 +08:00
2c55a53c3a update pay function 2025-11-25 16:31:46 +08:00
6ad56b9882 update pay function 2025-11-25 16:20:39 +08:00
b9eddbe752 update pay function 2025-11-25 15:28:12 +08:00
zdl
cb9f927e3e feat: 调整逻辑如果用户未登录且不在首页,跳转到首页 2025-11-25 14:28:20 +08:00
zdl
b9a587bac4 feat: 去掉logger 2025-11-25 14:28:20 +08:00
zdl
86259793cb feat: bug修复 2025-11-25 14:28:19 +08:00
f76bd17160 update pay function 2025-11-25 11:22:34 +08:00
ce0e91a5fb update pay function 2025-11-25 10:16:04 +08:00
f873fdb9a6 update pay function 2025-11-25 10:09:47 +08:00
cc446fc0da update pay function 2025-11-25 09:50:12 +08:00
de30755271 update pay function 2025-11-25 08:08:01 +08:00
a2f33c2a8a update pay function 2025-11-25 08:00:56 +08:00
761fe5d2f0 update pay function 2025-11-25 07:50:33 +08:00
3677217fce update pay function 2025-11-25 07:35:15 +08:00
177c1d6401 update pay function 2025-11-24 23:45:58 +08:00
fb066aa6b8 update pay function 2025-11-24 23:18:12 +08:00
96bedb8439 update pay function 2025-11-24 21:23:09 +08:00
83d7c19fed update pay function 2025-11-24 20:15:19 +08:00
e80d2cfcec update pay function 2025-11-24 20:06:51 +08:00
412f2a3d79 update pay function 2025-11-24 19:49:42 +08:00
4a0e156bec update pay function 2025-11-24 19:28:52 +08:00
7743a8a26a update pay function 2025-11-24 19:22:22 +08:00
72e3e56a63 update pay function 2025-11-24 19:07:24 +08:00
388e9eb235 Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref 2025-11-24 16:58:08 +08:00
bd23100192 update pay function 2025-11-24 16:58:02 +08:00
zdl
887525197a feat: StockChartAntdModal UI调整 2025-11-24 16:53:37 +08:00
zdl
f8bb46ae64 feat: 添加mock 2025-11-24 16:53:37 +08:00
810c878a1e update pay function 2025-11-24 16:49:04 +08:00
2607028f4f Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref 2025-11-24 16:39:47 +08:00
ea166d59c4 update pay function 2025-11-24 16:39:36 +08:00
zdl
982d8135e7 feat: bug修复 2025-11-24 16:38:33 +08:00
zdl
e61090810b Merge branch 'feature_2025/251117_pref' into feature_2025/251121_h5UI
* feature_2025/251117_pref: (159 commits)
  feat: UI调整
  feat: 将滚动事件移东到组件内部
  feat: 去掉背景组件
  feat: 拆分左侧栏、中间聊天区、右侧栏组件, Hooks 提取
  feat: 简化主组件 index.js - 使用组件组合方式重构
  feat: 创建 ChatArea 组件(含 MessageRenderer、ExecutionStepsDisplay 子组件)
  feat:拆分工具函数
  feat: 拆分BackgroundEffects 背景渐变装饰层
  feat: RightSidebar (~420 行) - 模型/工具/统计 Tab 面板(单文件)
  feat:  LeftSidebar (~280 行) - 对话历史列表 + 用户信息卡片
  feat: 修复bug
  pref:移除黑夜模式
  feat: 修复警告
  feat: 提取常量配置
  feat: 修复ts报错
  feat:  StockChartModal.tsx 替换 KLine 实现
  update pay function
  update pay function
  update pay function
  update pay function
  ...
2025-11-24 16:32:24 +08:00
zdl
2d49af3bea feat: UI调整 2025-11-24 16:11:13 +08:00
zdl
3a0898634f feat: 将滚动事件移东到组件内部 2025-11-24 15:54:26 +08:00
zdl
44ecf7e5c7 feat: 去掉背景组件 2025-11-24 15:47:23 +08:00
zdl
baf4ca1ed4 feat: 屏蔽 STOMP WebSocket 错误日志(不影响功能) 2025-11-21 18:45:13 +08:00
zdl
3cd34d93c8 Merge branch 'feature_2025/251117_pref' into feature_2025/251121_h5UI
* feature_2025/251117_pref:
  update pay function
  update pay function
  update pay function
  update pay function
  update pay function
  update pay function
  update pay function
2025-11-21 18:30:51 +08:00
zdl
c9084ebb33 feat: Socket.IO 连接地址(Mock 模式下连接生产环境) 2025-11-21 18:22:18 +08:00
zdl
ed584b72d4 feat: 创建整合所有指标的 Hook 2025-11-21 18:14:29 +08:00
zdl
2dec587d37 feat: 扩展 PostHog 事件常量 2025-11-21 18:13:53 +08:00
zdl
7f021dcfa0 feat; 创建首屏指标收集 Hook 2025-11-21 18:13:33 +08:00
zdl
e34f5593b4 feat: 创建资源加载监控工具 2025-11-21 18:12:58 +08:00
zdl
5f76530e80 feat: 创建 Web Vitals 监控工具 2025-11-21 18:12:34 +08:00
zdl
d6c7d64e59 feat: 创建性能阈值配置 2025-11-21 18:11:26 +08:00
zdl
ceed71eca4 feat: 创建 TypeScript 类型定义 2025-11-21 18:11:03 +08:00
zdl
9669d5709e fix: 在 craco.config.js 中将 /bytedesk 代理移出 Mock 模式条件判断
现在 /bytedesk 代理始终启用,指向 https://valuefrontier.cn
2025-11-21 18:06:21 +08:00
zdl
34bae35858 fix: 修复的问题:H5 汉堡菜单位置调整(移到头像右侧)
平板端显示 MoreMenu 而非汉堡菜单
未登录时不显示汉堡菜单
2025-11-21 17:59:03 +08:00
zdl
bc50d9fe3e fix: 修复的问题: │ │
│ │ -  React 18 Portal insertBefore 错误                                                                                                                               │ │
│ │ -  Ant Design Modal defaultProps 废弃警告
2025-11-21 17:46:07 +08:00
zdl
39978c57d5 pref: src/views/LimitAnalyse 页面 "数据分析"卡片中的"热词云图" 依赖更新 2025-11-21 17:37:56 +08:00
zdl
834067f679 fix: 修改 GlobalComponents.js(缓存 config)登录时不会触发 BytedeskWidget 重新加载 2025-11-21 14:38:09 +08:00
zdl
e8b3d13c0a feat: 桌面端导航判断调整 2025-11-21 14:07:04 +08:00
zdl
796c623197 fix:优化h5/菜单UI 2025-11-21 13:55:06 +08:00
49 changed files with 5423 additions and 1214 deletions

View File

@@ -18,3 +18,8 @@ REACT_APP_ENABLE_MOCK=false
# 开发环境标识
REACT_APP_ENV=development
# 性能监控配置
REACT_APP_ENABLE_PERFORMANCE_MONITOR=true
REACT_APP_ENABLE_PERFORMANCE_PANEL=true
REACT_APP_REPORT_TO_POSTHOG=false

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

@@ -37,3 +37,11 @@ TSC_COMPILE_ON_ERROR=true
IMAGE_INLINE_SIZE_LIMIT=10000
# Node.js 内存限制(适用于大型项目)
NODE_OPTIONS=--max_old_space_size=4096
# 性能监控配置(生产环境)
# 启用性能监控
REACT_APP_ENABLE_PERFORMANCE_MONITOR=true
# 禁用性能面板(仅开发环境)
REACT_APP_ENABLE_PERFORMANCE_PANEL=false
# 启用 PostHog 性能数据上报
REACT_APP_REPORT_TO_POSTHOG=true

129
app.py
View File

@@ -795,6 +795,9 @@ class PaymentOrder(db.Model):
plan_name = db.Column(db.String(20), nullable=False)
billing_cycle = db.Column(db.String(10), nullable=False)
amount = db.Column(db.Numeric(10, 2), nullable=False)
original_amount = db.Column(db.Numeric(10, 2), nullable=True) # 原价
discount_amount = db.Column(db.Numeric(10, 2), nullable=True, default=0) # 折扣金额
promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=True) # 优惠码ID
wechat_order_id = db.Column(db.String(64), nullable=True)
prepay_id = db.Column(db.String(64), nullable=True)
qr_code_url = db.Column(db.String(200), nullable=True)
@@ -804,11 +807,16 @@ class PaymentOrder(db.Model):
expired_at = db.Column(db.DateTime, nullable=True)
remark = db.Column(db.String(200), nullable=True)
def __init__(self, user_id, plan_name, billing_cycle, amount):
# 关联优惠码
promo_code = db.relationship('PromoCode', backref='orders', lazy=True, foreign_keys=[promo_code_id])
def __init__(self, user_id, plan_name, billing_cycle, amount, original_amount=None, discount_amount=0):
self.user_id = user_id
self.plan_name = plan_name
self.billing_cycle = billing_cycle
self.amount = amount
self.original_amount = original_amount if original_amount is not None else amount
self.discount_amount = discount_amount or 0
import random
timestamp = int(beijing_now().timestamp() * 1000000)
random_suffix = random.randint(1000, 9999)
@@ -837,10 +845,9 @@ class PaymentOrder(db.Model):
'plan_name': self.plan_name,
'billing_cycle': self.billing_cycle,
'amount': float(self.amount) if self.amount else 0,
'original_amount': float(self.original_amount) if hasattr(self, 'original_amount') and self.original_amount else None,
'discount_amount': float(self.discount_amount) if hasattr(self, 'discount_amount') and self.discount_amount else 0,
'promo_code': self.promo_code.code if hasattr(self, 'promo_code') and self.promo_code else None,
'is_upgrade': self.is_upgrade if hasattr(self, 'is_upgrade') else False,
'original_amount': float(self.original_amount) if self.original_amount else None,
'discount_amount': float(self.discount_amount) if self.discount_amount else 0,
'promo_code': self.promo_code.code if self.promo_code else None,
'qr_code_url': self.qr_code_url,
'status': self.status,
'is_expired': self.is_expired(),
@@ -1917,11 +1924,17 @@ def create_payment_order():
# 创建订单
try:
# 获取原价和折扣金额
original_amount = price_result.get('original_amount', amount)
discount_amount = price_result.get('discount_amount', 0)
order = PaymentOrder(
user_id=session['user_id'],
plan_name=plan_name,
billing_cycle=billing_cycle,
amount=amount
amount=amount,
original_amount=original_amount,
discount_amount=discount_amount
)
# 添加订阅类型标记(用于前端展示)
@@ -1931,12 +1944,8 @@ def create_payment_order():
if promo_code and price_result.get('promo_code'):
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
if promo_obj:
# 注意:需要在 PaymentOrder 表中添加 promo_code_id 字段
# 如果没有该字段,这行会报错,可以注释掉
try:
order.promo_code_id = promo_obj.id
except:
pass # 如果表中没有该字段,跳过
order.promo_code_id = promo_obj.id
print(f"📦 订单关联优惠码: {promo_obj.code} (ID: {promo_obj.id})")
db.session.add(order)
db.session.commit()
@@ -2058,6 +2067,29 @@ def check_order_status(order_id):
# 激活用户订阅
activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle)
# 记录优惠码使用情况
if order.promo_code_id:
try:
existing_usage = PromoCodeUsage.query.filter_by(order_id=order.id).first()
if not existing_usage:
usage = PromoCodeUsage(
promo_code_id=order.promo_code_id,
user_id=order.user_id,
order_id=order.id,
original_amount=order.original_amount or order.amount,
discount_amount=order.discount_amount or 0,
final_amount=order.amount
)
db.session.add(usage)
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses = (promo.current_uses or 0) + 1
print(f"🎫 优惠码使用记录已创建: {promo.code}")
except Exception as e:
print(f"⚠️ 记录优惠码使用失败: {e}")
db.session.commit()
return jsonify({
'success': True,
'data': order.to_dict(),
@@ -2136,24 +2168,30 @@ def force_update_order_status(order_id):
activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle)
# 记录优惠码使用(如果使用了优惠码)
if hasattr(order, 'promo_code_id') and order.promo_code_id:
if order.promo_code_id:
try:
promo_usage = PromoCodeUsage(
promo_code_id=order.promo_code_id,
user_id=order.user_id,
order_id=order.id,
original_amount=order.original_amount if hasattr(order, 'original_amount') else order.amount,
discount_amount=order.discount_amount if hasattr(order, 'discount_amount') else 0,
final_amount=order.amount
)
db.session.add(promo_usage)
# 检查是否已经记录过(防止重复)
existing_usage = PromoCodeUsage.query.filter_by(order_id=order.id).first()
if not existing_usage:
promo_usage = PromoCodeUsage(
promo_code_id=order.promo_code_id,
user_id=order.user_id,
order_id=order.id,
original_amount=order.original_amount or order.amount,
discount_amount=order.discount_amount or 0,
final_amount=order.amount
)
db.session.add(promo_usage)
# 更新优惠码使用次数
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses = (promo.current_uses or 0) + 1
# 更新优惠码使用次数
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses = (promo.current_uses or 0) + 1
print(f"🎫 优惠码使用记录已创建: {promo.code}")
else:
print(f" 优惠码使用记录已存在,跳过")
except Exception as e:
print(f"记录优惠码使用失败: {e}")
print(f"⚠️ 记录优惠码使用失败: {e}")
db.session.commit()
@@ -2254,6 +2292,37 @@ def wechat_payment_callback():
else:
print(f"⚠️ 订阅激活失败,但订单已标记为已支付")
# 记录优惠码使用情况
if order.promo_code_id:
try:
# 检查是否已经记录过(防止重复)
existing_usage = PromoCodeUsage.query.filter_by(
order_id=order.id
).first()
if not existing_usage:
# 创建优惠码使用记录
usage = PromoCodeUsage(
promo_code_id=order.promo_code_id,
user_id=order.user_id,
order_id=order.id,
original_amount=order.original_amount or order.amount,
discount_amount=order.discount_amount or 0,
final_amount=order.amount
)
db.session.add(usage)
# 更新优惠码使用次数
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses = (promo.current_uses or 0) + 1
print(f"🎫 优惠码使用记录已创建: {promo.code}, 当前使用次数: {promo.current_uses}")
else:
print(f" 优惠码使用记录已存在,跳过")
except Exception as e:
print(f"⚠️ 记录优惠码使用失败: {e}")
# 不影响主流程,继续执行
db.session.commit()
# 返回成功响应给微信
@@ -6904,18 +6973,18 @@ def get_daily_kline(stock_code, event_datetime, stock_name):
stock_code = stock_code.split('.')[0]
with engine.connect() as conn:
# 获取事件日期前后的数据
# 获取事件日期前后的数据前365天/1年后30天
kline_sql = """
WITH date_range AS (SELECT TRADEDATE \
FROM ea_trade \
WHERE SECCODE = :stock_code \
AND TRADEDATE BETWEEN DATE_SUB(:trade_date, INTERVAL 60 DAY) \
AND TRADEDATE BETWEEN DATE_SUB(:trade_date, INTERVAL 365 DAY) \
AND DATE_ADD(:trade_date, INTERVAL 30 DAY) \
GROUP BY TRADEDATE \
ORDER BY TRADEDATE)
SELECT t.TRADEDATE,
CAST(t.F003N AS FLOAT) as open,
CAST(t.F007N AS FLOAT) as close,
CAST(t.F007N AS FLOAT) as close,
CAST(t.F005N AS FLOAT) as high,
CAST(t.F006N AS FLOAT) as low,
CAST(t.F004N AS FLOAT) as volume

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,11 @@
"@splidejs/react-splide": "^0.7.12",
"@tanstack/react-virtual": "^3.13.12",
"@tippyjs/react": "^4.2.6",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.0.0",
"@visx/responsive": "^3.12.0",
"@visx/scale": "^3.12.0",
"@visx/text": "^3.12.0",
"@visx/visx": "^3.12.0",
"@visx/wordcloud": "^3.12.0",
"antd": "^5.27.4",
"apexcharts": "^3.27.3",
"axios": "^1.10.0",
@@ -53,6 +55,7 @@
"react-github-btn": "^1.2.1",
"react-icons": "^4.12.0",
"react-input-pin-code": "^1.1.5",
"react-is": "^19.0.0",
"react-just-parallax": "^3.1.16",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
@@ -60,11 +63,12 @@
"react-responsive-masonry": "^2.7.1",
"react-router-dom": "^6.30.1",
"react-scripts": "^5.0.1",
"react-is": "^19.0.0",
"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": "^3.0.3",
"react-tsparticles": "^2.12.2",
"recharts": "^3.1.2",
"sass": "^1.49.9",
"socket.io-client": "^4.7.4",

View File

@@ -21,6 +21,7 @@ import AppProviders from './providers/AppProviders';
// Components
import GlobalComponents from './components/GlobalComponents';
import { PerformancePanel } from './components/PerformancePanel';
// Hooks
import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler';
@@ -132,6 +133,7 @@ export default function App() {
<AppProviders>
<AppContent />
<GlobalComponents />
<PerformancePanel />
</AppProviders>
);
}

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

@@ -85,15 +85,15 @@ export default function AuthFormContent() {
const [showNicknamePrompt, setShowNicknamePrompt] = useState(false);
const [currentPhone, setCurrentPhone] = useState("");
// 响应式布局配置
// 响应式断点
const isMobile = useBreakpointValue({ base: true, md: false });
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
// 事件追踪
const authEvents = useAuthEvents({
component: 'AuthFormContent',
isMobile: isMobile
isMobile,
});
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px更紧凑
// 表单数据
@@ -186,8 +186,6 @@ export default function AuthFormContent() {
purpose: config.api.purpose
};
logger.api.request('POST', '/api/auth/send-verification-code', requestData);
const response = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
@@ -203,8 +201,6 @@ export default function AuthFormContent() {
const data = await response.json();
logger.api.response('POST', '/api/auth/send-verification-code', response.status, data);
if (!isMountedRef.current) return;
if (!data) {
@@ -309,12 +305,6 @@ export default function AuthFormContent() {
login_type: 'phone',
};
logger.api.request('POST', '/api/auth/login-with-code', {
credential: cleanedPhone.substring(0, 3) + '****' + cleanedPhone.substring(7),
verification_code: verificationCode.substring(0, 2) + '****',
login_type: 'phone'
});
// 调用API根据模式选择不同的endpoint
const response = await fetch('/api/auth/login-with-code', {
method: 'POST',
@@ -331,11 +321,6 @@ export default function AuthFormContent() {
const data = await response.json();
logger.api.response('POST', '/api/auth/login-with-code', response.status, {
...data,
user: data.user ? { id: data.user.id, phone: data.user.phone } : null
});
if (!isMountedRef.current) return;
if (!data) {

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,384 @@
// src/components/PerformancePanel.tsx
// 性能监控可视化面板 - 仅开发环境显示
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Button,
IconButton,
Stat,
StatLabel,
StatNumber,
StatHelpText,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
useDisclosure,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
} from '@chakra-ui/react';
import { MdSpeed, MdClose, MdRefresh, MdFileDownload } from 'react-icons/md';
import { performanceMonitor } from '@/utils/performanceMonitor';
/**
* 性能评分颜色映射
*/
const getScoreColor = (score: string): string => {
switch (score) {
case 'excellent':
return 'green';
case 'good':
return 'blue';
case 'needs improvement':
return 'yellow';
case 'poor':
return 'red';
default:
return 'gray';
}
};
/**
* 格式化毫秒数
*/
const formatMs = (ms: number | undefined): string => {
if (ms === undefined) return 'N/A';
return `${ms.toFixed(0)}ms`;
};
/**
* 性能面板组件
*/
export const PerformancePanel: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [report, setReport] = useState<any>(null);
// 刷新性能数据
const refreshData = () => {
const newReport = performanceMonitor.getReport();
setReport(newReport);
};
// 导出 JSON
const exportJSON = () => {
const json = performanceMonitor.exportJSON();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `performance-report-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 初始加载
useEffect(() => {
refreshData();
}, []);
// 仅在开发环境显示
if (process.env.NODE_ENV !== 'development') {
return null;
}
return (
<>
{/* 浮动按钮 */}
<IconButton
aria-label="Open performance panel"
icon={<MdSpeed />}
position="fixed"
bottom="20px"
right="20px"
colorScheme="blue"
size="lg"
borderRadius="full"
boxShadow="lg"
zIndex={9999}
onClick={onOpen}
/>
{/* 抽屉面板 */}
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="lg">
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader borderBottomWidth="1px">
<HStack>
<MdSpeed size={24} />
<Text></Text>
</HStack>
</DrawerHeader>
<DrawerBody>
{report ? (
<VStack spacing={4} align="stretch" py={4}>
{/* 操作按钮 */}
<HStack spacing={2}>
<Button
leftIcon={<MdRefresh />}
size="sm"
colorScheme="blue"
onClick={refreshData}
>
</Button>
<Button
leftIcon={<MdFileDownload />}
size="sm"
colorScheme="green"
onClick={exportJSON}
>
JSON
</Button>
</HStack>
{/* 总览 */}
<Box p={4} bg="gray.50" borderRadius="md">
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text fontWeight="bold"></Text>
<Badge
colorScheme={getScoreColor(report.summary.performanceScore)}
fontSize="md"
px={3}
py={1}
borderRadius="full"
>
{report.summary.performanceScore.toUpperCase()}
</Badge>
</HStack>
<HStack justify="space-between">
<Text fontSize="sm" color="gray.600">
: {report.summary.totalMarks}
</Text>
<Text fontSize="sm" color="gray.600">
: {report.summary.totalMeasures}
</Text>
</HStack>
</VStack>
</Box>
{/* 网络指标 */}
<Box>
<Text fontWeight="bold" mb={2}>
</Text>
<VStack align="stretch" spacing={2}>
<MetricStat
label="DNS 查询"
value={formatMs(report.metrics.dns)}
threshold={100}
actualValue={report.metrics.dns}
/>
<MetricStat
label="TCP 连接"
value={formatMs(report.metrics.tcp)}
threshold={100}
actualValue={report.metrics.tcp}
/>
<MetricStat
label="TTFB"
value={formatMs(report.metrics.ttfb)}
threshold={500}
actualValue={report.metrics.ttfb}
/>
</VStack>
</Box>
{/* 渲染指标 */}
<Box>
<Text fontWeight="bold" mb={2}>
</Text>
<VStack align="stretch" spacing={2}>
<MetricStat
label="FP (首次绘制)"
value={formatMs(report.metrics.fp)}
threshold={1000}
actualValue={report.metrics.fp}
/>
<MetricStat
label="FCP (首次内容绘制)"
value={formatMs(report.metrics.fcp)}
threshold={1800}
actualValue={report.metrics.fcp}
/>
<MetricStat
label="LCP (最大内容绘制)"
value={formatMs(report.metrics.lcp)}
threshold={2500}
actualValue={report.metrics.lcp}
/>
</VStack>
</Box>
{/* React 指标 */}
<Box>
<Text fontWeight="bold" mb={2}>
React
</Text>
<VStack align="stretch" spacing={2}>
<MetricStat
label="React 初始化"
value={formatMs(report.metrics.reactInit)}
threshold={1000}
actualValue={report.metrics.reactInit}
/>
<MetricStat
label="认证检查"
value={formatMs(report.metrics.authCheck)}
threshold={300}
actualValue={report.metrics.authCheck}
/>
<MetricStat
label="首页渲染"
value={formatMs(report.metrics.homepageRender)}
threshold={500}
actualValue={report.metrics.homepageRender}
/>
</VStack>
</Box>
{/* 总白屏时间 */}
<Box p={4} bg="blue.50" borderRadius="md" borderWidth="2px" borderColor="blue.200">
<Stat>
<StatLabel></StatLabel>
<StatNumber fontSize="3xl">
{formatMs(report.metrics.totalWhiteScreen)}
</StatNumber>
<StatHelpText>
{report.metrics.totalWhiteScreen && report.metrics.totalWhiteScreen < 1500
? '✅ 优秀'
: report.metrics.totalWhiteScreen && report.metrics.totalWhiteScreen < 2000
? '⚠️ 良好'
: '❌ 需要优化'}
</StatHelpText>
</Stat>
</Box>
{/* 优化建议 */}
<Accordion allowToggle>
<AccordionItem>
<h2>
<AccordionButton>
<Box flex="1" textAlign="left" fontWeight="bold">
({report.recommendations.length})
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<VStack align="stretch" spacing={2}>
{report.recommendations.map((rec: string, index: number) => (
<Text key={index} fontSize="sm">
{rec}
</Text>
))}
</VStack>
</AccordionPanel>
</AccordionItem>
{/* 性能标记 */}
<AccordionItem>
<h2>
<AccordionButton>
<Box flex="1" textAlign="left" fontWeight="bold">
({report.marks.length})
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<VStack align="stretch" spacing={1}>
{report.marks.map((mark: any, index: number) => (
<HStack key={index} justify="space-between" fontSize="sm">
<Text>{mark.name}</Text>
<Text color="gray.600">{mark.time.toFixed(2)}ms</Text>
</HStack>
))}
</VStack>
</AccordionPanel>
</AccordionItem>
{/* 性能测量 */}
<AccordionItem>
<h2>
<AccordionButton>
<Box flex="1" textAlign="left" fontWeight="bold">
({report.measures.length})
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<VStack align="stretch" spacing={2}>
{report.measures.map((measure: any, index: number) => (
<Box key={index} p={2} bg="gray.50" borderRadius="md">
<HStack justify="space-between">
<Text fontWeight="semibold" fontSize="sm">
{measure.name}
</Text>
<Badge>{measure.duration.toFixed(2)}ms</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{measure.startMark} {measure.endMark}
</Text>
</Box>
))}
</VStack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</VStack>
) : (
<Text>...</Text>
)}
</DrawerBody>
</DrawerContent>
</Drawer>
</>
);
};
/**
* 指标统计组件
*/
interface MetricStatProps {
label: string;
value: string;
threshold: number;
actualValue?: number;
}
const MetricStat: React.FC<MetricStatProps> = ({ label, value, threshold, actualValue }) => {
const isGood = actualValue !== undefined && actualValue < threshold;
return (
<HStack justify="space-between" p={2} bg="gray.50" borderRadius="md">
<Text fontSize="sm">{label}</Text>
<HStack>
<Text fontSize="sm" fontWeight="bold">
{value}
</Text>
{actualValue !== undefined && (
<Text fontSize="xs">{isGood ? '✅' : '⚠️'}</Text>
)}
</HStack>
</HStack>
);
};
export default PerformancePanel;

View File

@@ -0,0 +1,614 @@
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import * as echarts from 'echarts';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/**
* 股票信息
*/
interface StockInfo {
stock_code: string;
stock_name?: string;
}
/**
* KLineChartModal 组件 Props
*/
export interface KLineChartModalProps {
/** 模态框是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 股票信息 */
stock: StockInfo | null;
/** 事件时间 */
eventTime?: string | null;
/** 模态框大小 */
size?: string;
}
/**
* K线数据点
*/
interface KLineDataPoint {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
const KLineChartModal: React.FC<KLineChartModalProps> = ({
isOpen,
onClose,
stock,
eventTime,
size = '5xl',
}) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<KLineDataPoint[]>([]);
// 调试日志
console.log('[KLineChartModal] 渲染状态:', {
isOpen,
stock,
eventTime,
dataLength: data.length,
loading,
error
});
// 加载K线数据
const loadData = async () => {
if (!stock?.stock_code) return;
setLoading(true);
setError(null);
try {
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
stockCode: stock.stock_code,
eventTime,
});
const response = await stockService.getKlineData(
stock.stock_code,
'daily',
eventTime || undefined
);
console.log('[KLineChartModal] API响应:', response);
if (!response || !response.data || response.data.length === 0) {
throw new Error('暂无K线数据');
}
console.log('[KLineChartModal] 数据条数:', response.data.length);
setData(response.data);
logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', {
dataCount: response.data.length,
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
logger.error('KLineChartModal', 'loadData', err as Error);
setError(errorMsg);
} finally {
setLoading(false);
}
};
// 初始化图表
useEffect(() => {
if (!isOpen) return;
// 延迟初始化,确保 Modal 动画完成后 DOM 已经渲染
const timer = setTimeout(() => {
if (!chartRef.current) {
console.error('[KLineChartModal] DOM元素未找到无法初始化图表');
return;
}
console.log('[KLineChartModal] 初始化图表...');
// 创建图表实例不使用主题直接在option中配置背景色
chartInstance.current = echarts.init(chartRef.current);
console.log('[KLineChartModal] 图表实例创建成功');
// 监听窗口大小变化
const handleResize = () => {
chartInstance.current?.resize();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, 100); // 延迟100ms等待Modal完全打开
return () => {
clearTimeout(timer);
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
}
};
}, [isOpen]);
// 更新图表数据
useEffect(() => {
if (data.length === 0) {
console.log('[KLineChartModal] 无数据,跳过图表更新');
return;
}
const updateChart = () => {
if (!chartInstance.current) {
console.warn('[KLineChartModal] 图表实例不存在');
return false;
}
console.log('[KLineChartModal] 开始更新图表,数据点:', data.length);
const dates = data.map((d) => d.time);
const klineData = data.map((d) => [d.open, d.close, d.low, d.high]);
const volumes = data.map((d) => d.volume);
// 计算成交量柱子颜色(涨为红,跌为绿)
const volumeColors = data.map((d) =>
d.close >= d.open ? '#ef5350' : '#26a69a'
);
// 提取事件发生日期YYYY-MM-DD格式
let eventDateStr: string | null = null;
if (eventTime) {
try {
const eventDate = new Date(eventTime);
const year = eventDate.getFullYear();
const month = (eventDate.getMonth() + 1).toString().padStart(2, '0');
const day = eventDate.getDate().toString().padStart(2, '0');
eventDateStr = `${year}-${month}-${day}`;
console.log('[KLineChartModal] 事件发生日期:', eventDateStr);
} catch (e) {
console.error('[KLineChartModal] 解析事件日期失败:', e);
}
}
// 图表配置
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
left: 'center',
top: 10,
textStyle: {
color: '#e0e0e0',
fontSize: 18,
fontWeight: 'bold',
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(30, 30, 30, 0.95)',
borderColor: '#404040',
borderWidth: 1,
textStyle: {
color: '#e0e0e0',
},
axisPointer: {
type: 'cross',
crossStyle: {
color: '#999',
},
},
formatter: (params: any) => {
const dataIndex = params[0]?.dataIndex;
if (dataIndex === undefined) return '';
const item = data[dataIndex];
const change = item.close - item.open;
const changePercent = (change / item.open) * 100;
const changeColor = change >= 0 ? '#ef5350' : '#26a69a';
const changeSign = change >= 0 ? '+' : '';
return `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 8px;">${item.time}</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>开盘:</span>
<span style="margin-left: 20px;">${item.open.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>收盘:</span>
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${item.close.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>最高:</span>
<span style="margin-left: 20px;">${item.high.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>最低:</span>
<span style="margin-left: 20px;">${item.low.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>涨跌额:</span>
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${change.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>涨跌幅:</span>
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${changePercent.toFixed(2)}%</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>成交量:</span>
<span style="margin-left: 20px;">${(item.volume / 100).toFixed(0)}手</span>
</div>
</div>
`;
},
},
grid: [
{
left: '5%',
right: '5%',
top: '12%',
height: '60%',
},
{
left: '5%',
right: '5%',
top: '77%',
height: '18%',
},
],
xAxis: [
{
type: 'category',
data: dates,
gridIndex: 0,
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
interval: Math.floor(dates.length / 8),
},
splitLine: {
show: false,
},
},
{
type: 'category',
data: dates,
gridIndex: 1,
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
interval: Math.floor(dates.length / 8),
},
},
],
yAxis: [
{
scale: true,
gridIndex: 0,
splitLine: {
show: true,
lineStyle: {
color: '#2a2a2a',
},
},
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
formatter: (value: number) => value.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitLine: {
show: false,
},
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
formatter: (value: number) => {
if (value >= 100000000) {
return (value / 100000000).toFixed(1) + '亿';
} else if (value >= 10000) {
return (value / 10000).toFixed(1) + '万';
}
return value.toFixed(0);
},
},
},
],
series: [
{
name: 'K线',
type: 'candlestick',
data: klineData,
xAxisIndex: 0,
yAxisIndex: 0,
itemStyle: {
color: '#ef5350', // 涨
color0: '#26a69a', // 跌
borderColor: '#ef5350',
borderColor0: '#26a69a',
},
markLine: eventDateStr ? {
silent: false,
symbol: 'none',
label: {
show: true,
position: 'insideEndTop',
formatter: '事件发生',
color: '#ffd700',
fontSize: 12,
fontWeight: 'bold',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: [4, 8],
borderRadius: 4,
},
lineStyle: {
color: '#ffd700',
width: 2,
type: 'solid',
},
data: [
{
xAxis: eventDateStr,
label: {
formatter: '⚡ 事件发生',
},
},
],
} : undefined,
},
{
name: '成交量',
type: 'bar',
data: volumes,
xAxisIndex: 1,
yAxisIndex: 1,
itemStyle: {
color: (params: any) => {
return volumeColors[params.dataIndex];
},
},
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 0,
end: 100,
},
],
};
chartInstance.current.setOption(option);
console.log('[KLineChartModal] 图表option已设置');
// 强制resize以确保图表正确显示
setTimeout(() => {
chartInstance.current?.resize();
console.log('[KLineChartModal] 图表已resize');
}, 100);
return true;
};
// 立即尝试更新,如果失败则重试
if (!updateChart()) {
console.log('[KLineChartModal] 第一次更新失败200ms后重试...');
const retryTimer = setTimeout(() => {
updateChart();
}, 200);
return () => clearTimeout(retryTimer);
}
}, [data, stock]);
// 加载数据
useEffect(() => {
if (isOpen) {
loadData();
}
}, [isOpen, stock?.stock_code, eventTime]);
// 创建或获取 Portal 容器
useEffect(() => {
let container = document.getElementById('kline-modal-root');
if (!container) {
container = document.createElement('div');
container.id = 'kline-modal-root';
container.style.cssText = 'position: fixed; top: 0; left: 0; z-index: 10000;';
document.body.appendChild(container);
}
return () => {
// 组件卸载时不删除容器,因为可能会被复用
};
}, []);
if (!stock) return null;
console.log('[KLineChartModal] 渲染 Modal, isOpen:', isOpen);
// 获取 Portal 容器
const portalContainer = document.getElementById('kline-modal-root') || document.body;
// 如果不显示则返回 null
if (!isOpen) return null;
const modalContent = (
<>
{/* 遮罩层 */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
zIndex: 10001,
}}
onClick={onClose}
/>
{/* 弹窗内容 */}
<div
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '1400px',
maxHeight: '85vh',
backgroundColor: '#1a1a1a',
border: '2px solid #ffd700',
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
borderRadius: '8px',
zIndex: 10002,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid #404040',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</span>
{data.length > 0 && (
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
{data.length}1
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
<span style={{ fontSize: '14px', color: '#999' }}>K线图</span>
<span style={{ fontSize: '12px', color: '#666' }}>
💡 |
</span>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: '#999',
fontSize: '24px',
cursor: 'pointer',
padding: '0 8px',
lineHeight: '1',
}}
onMouseOver={(e) => (e.currentTarget.style.color = '#e0e0e0')}
onMouseOut={(e) => (e.currentTarget.style.color = '#999')}
>
×
</button>
</div>
{/* Body */}
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
{error && (
<div
style={{
backgroundColor: '#2a1a1a',
border: '1px solid #ef5350',
borderRadius: '4px',
padding: '12px 16px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span style={{ color: '#ef5350' }}></span>
<span style={{ color: '#e0e0e0' }}>{error}</span>
</div>
)}
<div style={{ position: 'relative', height: '680px', width: '100%' }}>
{loading && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(26, 26, 26, 0.7)',
zIndex: 10,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
}}
>
<div
style={{
width: '40px',
height: '40px',
border: '3px solid #404040',
borderTop: '3px solid #3182ce',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
<span style={{ color: '#e0e0e0' }}>K线数据...</span>
</div>
)}
<div ref={chartRef} style={{ width: '100%', height: '100%' }} />
</div>
</div>
</div>
{/* 添加旋转动画的 CSS */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</>
);
return createPortal(modalContent, portalContainer);
};
export default KLineChartModal;

View File

@@ -507,8 +507,9 @@ const StockChartAntdModal = ({
footer={null}
onCancel={onCancel}
width={width}
style={{ position: fixed ? 'fixed' : 'absolute', left: fixed ? 50 : 0, top: fixed ? 50 : 80, zIndex: 2000 }}
mask={false}
centered
zIndex={2500}
mask={true}
destroyOnClose={true}
bodyStyle={{ maxHeight: 'calc(90vh - 120px)', overflowY: 'auto', padding: '16px' }}
>

View File

@@ -18,8 +18,6 @@ import {
CircularProgress,
} from '@chakra-ui/react';
import RiskDisclaimer from '../RiskDisclaimer';
import { RelationDescription } from '../StockRelation';
import type { RelationDescType } from '../StockRelation';
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
import { Alert, AlertIcon } from '@chakra-ui/react';
@@ -34,7 +32,6 @@ type ChartType = 'timeline' | 'daily';
interface StockInfo {
stock_code: string;
stock_name?: string;
relation_desc?: RelationDescType;
}
/**
@@ -197,9 +194,6 @@ const StockChartModal: React.FC<StockChartModalProps> = ({
/>
</Box>
{/* 关联描述 */}
<RelationDescription relationDesc={stock?.relation_desc} />
{/* 风险提示 */}
<Box px={4} pb={4}>
<RiskDisclaimer text="" variant="default" sx={{}} />

View File

@@ -0,0 +1,514 @@
// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件
import React, { useEffect, useRef, useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
VStack,
HStack,
Text,
Box,
Flex,
CircularProgress,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import * as echarts from 'echarts';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/**
* 股票信息
*/
interface StockInfo {
stock_code: string;
stock_name?: string;
}
/**
* TimelineChartModal 组件 Props
*/
export interface TimelineChartModalProps {
/** 模态框是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 股票信息 */
stock: StockInfo | null;
/** 事件时间 */
eventTime?: string | null;
/** 模态框大小 */
size?: string;
}
/**
* 分时图数据点
*/
interface TimelineDataPoint {
time: string;
price: number;
avg_price: number;
volume: number;
change_percent: number;
}
const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
isOpen,
onClose,
stock,
eventTime,
size = '5xl',
}) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<TimelineDataPoint[]>([]);
// 加载分时图数据
const loadData = async () => {
if (!stock?.stock_code) return;
setLoading(true);
setError(null);
try {
logger.debug('TimelineChartModal', 'loadData', '开始加载分时图数据', {
stockCode: stock.stock_code,
eventTime,
});
const response = await stockService.getKlineData(
stock.stock_code,
'timeline',
eventTime || undefined
);
console.log('[TimelineChartModal] API响应:', response);
if (!response || !response.data || response.data.length === 0) {
throw new Error('暂无分时数据');
}
console.log('[TimelineChartModal] 数据条数:', response.data.length);
setData(response.data);
logger.info('TimelineChartModal', 'loadData', '分时图数据加载成功', {
dataCount: response.data.length,
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
logger.error('TimelineChartModal', 'loadData', err as Error);
setError(errorMsg);
} finally {
setLoading(false);
}
};
// 初始化图表
useEffect(() => {
if (!isOpen) return;
// 延迟初始化,确保 Modal 动画完成后 DOM 已经渲染
const timer = setTimeout(() => {
if (!chartRef.current) {
console.error('[TimelineChartModal] DOM元素未找到无法初始化图表');
return;
}
console.log('[TimelineChartModal] 初始化图表...');
// 创建图表实例不使用主题直接在option中配置背景色
chartInstance.current = echarts.init(chartRef.current);
console.log('[TimelineChartModal] 图表实例创建成功');
// 监听窗口大小变化
const handleResize = () => {
chartInstance.current?.resize();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, 100); // 延迟100ms等待Modal完全打开
return () => {
clearTimeout(timer);
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
}
};
}, [isOpen]);
// 更新图表数据
useEffect(() => {
if (data.length === 0) {
console.log('[TimelineChartModal] 无数据,跳过图表更新');
return;
}
// 如果图表还没初始化等待200ms后重试给图表初始化留出时间
const updateChart = () => {
if (!chartInstance.current) {
console.warn('[TimelineChartModal] 图表实例不存在');
return false;
}
console.log('[TimelineChartModal] 开始更新图表,数据点:', data.length);
const times = data.map((d) => d.time);
const prices = data.map((d) => d.price);
const avgPrices = data.map((d) => d.avg_price);
const volumes = data.map((d) => d.volume);
// 计算涨跌颜色
const basePrice = data[0]?.price || 0;
const volumeColors = data.map((d) =>
d.price >= basePrice ? '#ef5350' : '#26a69a'
);
// 提取事件发生时间HH:MM格式
let eventTimeStr: string | null = null;
if (eventTime) {
try {
const eventDate = new Date(eventTime);
const hours = eventDate.getHours().toString().padStart(2, '0');
const minutes = eventDate.getMinutes().toString().padStart(2, '0');
eventTimeStr = `${hours}:${minutes}`;
console.log('[TimelineChartModal] 事件发生时间:', eventTimeStr);
} catch (e) {
console.error('[TimelineChartModal] 解析事件时间失败:', e);
}
}
// 图表配置
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
left: 'center',
top: 10,
textStyle: {
color: '#e0e0e0',
fontSize: 18,
fontWeight: 'bold',
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(30, 30, 30, 0.95)',
borderColor: '#404040',
borderWidth: 1,
textStyle: {
color: '#e0e0e0',
},
axisPointer: {
type: 'cross',
crossStyle: {
color: '#999',
},
},
formatter: (params: any) => {
const dataIndex = params[0]?.dataIndex;
if (dataIndex === undefined) return '';
const item = data[dataIndex];
const changeColor = item.change_percent >= 0 ? '#ef5350' : '#26a69a';
const changeSign = item.change_percent >= 0 ? '+' : '';
return `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 8px;">${item.time}</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>价格:</span>
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${item.price.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>均价:</span>
<span style="color: #ffa726; margin-left: 20px;">${item.avg_price.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>涨跌幅:</span>
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${item.change_percent.toFixed(2)}%</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>成交量:</span>
<span style="margin-left: 20px;">${(item.volume / 100).toFixed(0)}手</span>
</div>
</div>
`;
},
},
grid: [
{
left: '5%',
right: '5%',
top: '15%',
height: '55%',
},
{
left: '5%',
right: '5%',
top: '75%',
height: '15%',
},
],
xAxis: [
{
type: 'category',
data: times,
gridIndex: 0,
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
interval: Math.floor(times.length / 6),
},
splitLine: {
show: true,
lineStyle: {
color: '#2a2a2a',
},
},
},
{
type: 'category',
data: times,
gridIndex: 1,
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
interval: Math.floor(times.length / 6),
},
},
],
yAxis: [
{
scale: true,
gridIndex: 0,
splitLine: {
show: true,
lineStyle: {
color: '#2a2a2a',
},
},
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
formatter: (value: number) => value.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitLine: {
show: false,
},
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
formatter: (value: number) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万';
}
return value.toFixed(0);
},
},
},
],
series: [
{
name: '价格',
type: 'line',
data: prices,
xAxisIndex: 0,
yAxisIndex: 0,
smooth: true,
symbol: 'none',
lineStyle: {
color: '#2196f3',
width: 2,
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(33, 150, 243, 0.3)' },
{ offset: 1, color: 'rgba(33, 150, 243, 0.05)' },
]),
},
markLine: eventTimeStr ? {
silent: false,
symbol: 'none',
label: {
show: true,
position: 'insideEndTop',
formatter: '事件发生',
color: '#ffd700',
fontSize: 12,
fontWeight: 'bold',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: [4, 8],
borderRadius: 4,
},
lineStyle: {
color: '#ffd700',
width: 2,
type: 'solid',
},
data: [
{
xAxis: eventTimeStr,
label: {
formatter: '⚡ 事件发生',
},
},
],
} : undefined,
},
{
name: '均价',
type: 'line',
data: avgPrices,
xAxisIndex: 0,
yAxisIndex: 0,
smooth: true,
symbol: 'none',
lineStyle: {
color: '#ffa726',
width: 1.5,
type: 'dashed',
},
},
{
name: '成交量',
type: 'bar',
data: volumes,
xAxisIndex: 1,
yAxisIndex: 1,
itemStyle: {
color: (params: any) => {
return volumeColors[params.dataIndex];
},
},
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 0,
end: 100,
},
],
};
chartInstance.current.setOption(option);
console.log('[TimelineChartModal] 图表option已设置');
// 强制resize以确保图表正确显示
setTimeout(() => {
chartInstance.current?.resize();
console.log('[TimelineChartModal] 图表已resize');
}, 100);
return true;
};
// 立即尝试更新,如果失败则重试
if (!updateChart()) {
console.log('[TimelineChartModal] 第一次更新失败200ms后重试...');
const retryTimer = setTimeout(() => {
updateChart();
}, 200);
return () => clearTimeout(retryTimer);
}
}, [data, stock]);
// 加载数据
useEffect(() => {
if (isOpen) {
loadData();
}
}, [isOpen, stock?.stock_code, eventTime]);
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size={size}>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent
maxW="90vw"
maxH="85vh"
bg="#1a1a1a"
borderColor="#404040"
borderWidth="1px"
>
<ModalHeader pb={3} borderBottomWidth="1px" borderColor="#404040">
<VStack align="flex-start" spacing={1}>
<HStack>
<Text fontSize="lg" fontWeight="bold" color="#e0e0e0">
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</Text>
</HStack>
<Text fontSize="sm" color="#999">
</Text>
</VStack>
</ModalHeader>
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
<ModalBody p={4}>
{error && (
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
<AlertIcon color="#ef5350" />
<Text color="#e0e0e0">{error}</Text>
</Alert>
)}
<Box position="relative" h="600px" w="100%">
{loading && (
<Flex
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
bg="rgba(26, 26, 26, 0.7)"
zIndex="10"
alignItems="center"
justifyContent="center"
>
<VStack spacing={4}>
<CircularProgress isIndeterminate color="blue.400" />
<Text color="#e0e0e0">...</Text>
</VStack>
</Flex>
)}
<div ref={chartRef} style={{ width: '100%', height: '100%' }} />
</Box>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default TimelineChartModal;

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

@@ -11,7 +11,6 @@ import {
selectRedirectUrl
} from '../store/slices/authModalSlice';
import { useAuth } from '../contexts/AuthContext';
import { logger } from '../utils/logger';
/**
* 认证弹窗自定义 Hook
@@ -49,27 +48,19 @@ export const useAuthModal = () => {
const openAuthModal = useCallback((url = null, callback = null) => {
onSuccessCallbackRef.current = callback;
dispatch(openModal({ redirectUrl: url }));
logger.debug('useAuthModal', '打开认证弹窗', {
redirectUrl: url || '无',
hasCallback: !!callback
});
}, [dispatch]);
/**
* 关闭认证弹窗
* 如果用户未登录,跳转到首页
* 如果用户未登录且不在首页,跳转到首页
*/
const closeAuthModal = useCallback(() => {
dispatch(closeModal());
onSuccessCallbackRef.current = null;
// ⭐ 如果用户关闭弹窗时仍未登录,跳转到首页
if (!isAuthenticated) {
// ⭐ 如果用户关闭弹窗时仍未登录,且当前不在首页,才跳转到首页
if (!isAuthenticated && window.location.pathname !== '/home') {
navigate('/home');
logger.debug('useAuthModal', '未登录关闭弹窗,跳转到首页');
} else {
logger.debug('useAuthModal', '关闭认证弹窗');
}
}, [dispatch, isAuthenticated, navigate]);
@@ -82,14 +73,8 @@ export const useAuthModal = () => {
if (onSuccessCallbackRef.current) {
try {
onSuccessCallbackRef.current(user);
logger.debug('useAuthModal', '执行成功回调', {
userId: user?.id
});
} catch (error) {
logger.error('useAuthModal', 'handleLoginSuccess 回调执行失败', error, {
userId: user?.id,
hasCallback: !!onSuccessCallbackRef.current
});
console.error('useAuthModal: handleLoginSuccess 回调执行失败', error);
}
}
@@ -97,10 +82,6 @@ export const useAuthModal = () => {
// 移除了原有的 redirectUrl 跳转逻辑
dispatch(closeModal());
onSuccessCallbackRef.current = null;
logger.debug('useAuthModal', '登录成功,关闭弹窗', {
userId: user?.id
});
}, [dispatch]);
return {

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

@@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter);
*/
export default function MainLayout() {
return (
<Box minH="100vh" display="flex" flexDirection="column">
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column">
{/* 导航栏 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedHomeNavbar />
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" w="100%" position="relative" overflow="hidden" pt="72px">
<Box flex="1" pt="72px">
<ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />
@@ -47,11 +47,11 @@ export default function MainLayout() {
<MemoizedAppFooter />
{/* 返回顶部按钮 - 滚动超过阈值时显示 */}
<BackToTopButton
{/* <BackToTopButton
scrollThreshold={BACK_TO_TOP_CONFIG.scrollThreshold}
position={BACK_TO_TOP_CONFIG.position}
zIndex={BACK_TO_TOP_CONFIG.zIndex}
/>
/> */}
</Box>
);
}

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

@@ -293,4 +293,105 @@ export const isFeatureEnabled = (flagKey) => {
}
};
/**
* Report performance metrics to PostHog
* @param {object} metrics - Performance metrics object
*/
export const reportPerformanceMetrics = (metrics) => {
// 仅在生产环境上报
if (process.env.NODE_ENV !== 'production') {
console.log('📊 [开发环境] 性能指标(未上报到 PostHog:', metrics);
return;
}
try {
// 获取浏览器和设备信息
const browserInfo = {
userAgent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
connection: navigator.connection?.effectiveType || 'unknown',
deviceMemory: navigator.deviceMemory || 'unknown',
hardwareConcurrency: navigator.hardwareConcurrency || 'unknown',
};
// 上报性能指标
posthog.capture('Performance Metrics', {
// 网络指标
dns_ms: metrics.dns,
tcp_ms: metrics.tcp,
ttfb_ms: metrics.ttfb,
dom_load_ms: metrics.domLoad,
resource_load_ms: metrics.resourceLoad,
// 渲染指标
fp_ms: metrics.fp,
fcp_ms: metrics.fcp,
lcp_ms: metrics.lcp,
// React 指标
react_init_ms: metrics.reactInit,
auth_check_ms: metrics.authCheck,
homepage_render_ms: metrics.homepageRender,
// 总计
total_white_screen_ms: metrics.totalWhiteScreen,
// 性能评分
performance_score: calculatePerformanceScore(metrics),
// 浏览器和设备信息
...browserInfo,
// 时间戳
timestamp: new Date().toISOString(),
});
console.log('✅ 性能指标已上报到 PostHog');
} catch (error) {
console.error('❌ PostHog 性能指标上报失败:', error);
}
};
/**
* Calculate overall performance score (0-100)
* @param {object} metrics - Performance metrics
* @returns {number} Score from 0 to 100
*/
const calculatePerformanceScore = (metrics) => {
let score = 100;
// 白屏时间评分(权重 40%
if (metrics.totalWhiteScreen) {
if (metrics.totalWhiteScreen > 3000) score -= 40;
else if (metrics.totalWhiteScreen > 2000) score -= 20;
else if (metrics.totalWhiteScreen > 1500) score -= 10;
}
// TTFB 评分(权重 20%
if (metrics.ttfb) {
if (metrics.ttfb > 1000) score -= 20;
else if (metrics.ttfb > 500) score -= 10;
}
// LCP 评分(权重 20%
if (metrics.lcp) {
if (metrics.lcp > 4000) score -= 20;
else if (metrics.lcp > 2500) score -= 10;
}
// FCP 评分(权重 10%
if (metrics.fcp) {
if (metrics.fcp > 3000) score -= 10;
else if (metrics.fcp > 1800) score -= 5;
}
// 认证检查评分(权重 10%
if (metrics.authCheck) {
if (metrics.authCheck > 500) score -= 10;
else if (metrics.authCheck > 300) score -= 5;
}
return Math.max(0, Math.min(100, score));
};
export default posthog;

233
src/mocks/handlers/agent.js Normal file
View File

@@ -0,0 +1,233 @@
// src/mocks/handlers/agent.js
// Agent Chat API Mock Handlers
import { http, HttpResponse, delay } from 'msw';
// 模拟会话数据
const mockSessions = [
{
session_id: 'session-001',
title: '贵州茅台投资分析',
created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
message_count: 15,
},
{
session_id: 'session-002',
title: '新能源板块研究',
created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
timestamp: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
message_count: 8,
},
{
session_id: 'session-003',
title: '半导体行业分析',
created_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
timestamp: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
message_count: 12,
},
];
// 模拟历史消息数据
const mockHistory = {
'session-001': [
{
message_type: 'user',
message: '分析一下贵州茅台的投资价值',
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
plan: null,
steps: null,
},
{
message_type: 'assistant',
message:
'# 贵州茅台投资价值分析\n\n根据最新数据贵州茅台600519.SH具有以下投资亮点\n\n## 基本面分析\n- **营收增长**2024年Q3营收同比增长12.5%\n- **净利润率**保持在50%以上的高水平\n- **ROE**连续10年超过20%\n\n## 估值分析\n- **PETTM**35.6倍,略高于历史中位数\n- **PB**10.2倍,处于合理区间\n\n## 投资建议\n建议关注价格回调机会长期配置价值显著。',
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000 + 5000).toISOString(),
plan: JSON.stringify({
goal: '分析贵州茅台的投资价值',
steps: [
{ step: 1, action: '获取贵州茅台最新股价和财务数据', reasoning: '需要基础数据支持分析' },
{ step: 2, action: '分析公司基本面和盈利能力', reasoning: '评估公司质量' },
{ step: 3, action: '对比行业估值水平', reasoning: '判断估值合理性' },
{ step: 4, action: '给出投资建议', reasoning: '综合判断投资价值' },
],
}),
steps: JSON.stringify([
{
tool: 'get_stock_info',
status: 'success',
result: '获取到贵州茅台最新数据股价1850元市值2.3万亿',
},
{
tool: 'analyze_financials',
status: 'success',
result: '财务分析完成:营收增长稳健,利润率行业领先',
},
]),
},
],
};
// 生成模拟的 Agent 响应
function generateAgentResponse(message, sessionId) {
const responses = {
包含: {
贵州茅台: {
plan: {
goal: '分析贵州茅台相关信息',
steps: [
{ step: 1, action: '搜索贵州茅台最新新闻', reasoning: '了解最新动态' },
{ step: 2, action: '获取股票实时行情', reasoning: '查看当前价格走势' },
{ step: 3, action: '分析财务数据', reasoning: '评估基本面' },
{ step: 4, action: '生成投资建议', reasoning: '综合判断' },
],
},
step_results: [
{
tool: 'search_news',
status: 'success',
result: '找到5条相关新闻茅台Q3业绩超预期...',
},
{
tool: 'get_stock_quote',
status: 'success',
result: '当前价格1850元涨幅+2.3%',
},
{
tool: 'analyze_financials',
status: 'success',
result: 'ROE: 25.6%, 净利润率: 52.3%',
},
],
final_summary:
'# 贵州茅台分析报告\n\n## 最新动态\n茅台Q3业绩超预期营收增长稳健。\n\n## 行情分析\n当前价格1850元今日上涨2.3%,成交量活跃。\n\n## 财务表现\n- ROE: 25.6%(行业领先)\n- 净利润率: 52.3%(极高水平)\n- 营收增长: 12.5% YoY\n\n## 投资建议\n**推荐关注**:基本面优秀,估值合理,建议逢低布局。',
},
新能源: {
plan: {
goal: '分析新能源行业',
steps: [
{ step: 1, action: '搜索新能源行业新闻', reasoning: '了解行业动态' },
{ step: 2, action: '获取新能源概念股', reasoning: '找到相关标的' },
{ step: 3, action: '分析行业趋势', reasoning: '判断投资机会' },
],
},
step_results: [
{
tool: 'search_news',
status: 'success',
result: '新能源政策利好频出,行业景气度提升',
},
{
tool: 'get_concept_stocks',
status: 'success',
result: '新能源板块共182只个股今日平均涨幅3.2%',
},
],
final_summary:
'# 新能源行业分析\n\n## 行业动态\n政策利好频出行业景气度持续提升。\n\n## 板块表现\n新能源板块今日强势上涨平均涨幅3.2%。\n\n## 投资机会\n建议关注龙头企业和细分赛道领导者。',
},
},
默认: {
plan: {
goal: '回答用户问题',
steps: [
{ step: 1, action: '理解用户意图', reasoning: '准确把握需求' },
{ step: 2, action: '搜索相关信息', reasoning: '获取数据支持' },
{ step: 3, action: '生成回复', reasoning: '提供专业建议' },
],
},
step_results: [
{
tool: 'search_related_info',
status: 'success',
result: '已找到相关信息',
},
],
final_summary: `我已经收到您的问题:"${message}"\n\n作为您的 AI 投研助手,我可以帮您:\n- 📊 分析股票基本面和技术面\n- 🔥 追踪市场热点和板块动态\n- 📈 研究行业趋势和投资机会\n- 📰 汇总最新财经新闻和研报\n\n请告诉我您想了解哪方面的信息?`,
},
};
// 根据关键词匹配响应
for (const keyword in responses.包含) {
if (message.includes(keyword)) {
return responses.包含[keyword];
}
}
return responses.默认;
}
// Agent Chat API Handlers
export const agentHandlers = [
// POST /mcp/agent/chat - 发送消息
http.post('/mcp/agent/chat', async ({ request }) => {
await delay(800); // 模拟网络延迟
const body = await request.json();
const { message, session_id, user_id, subscription_type } = body;
// 模拟权限检查(仅 max 用户可用)
if (subscription_type !== 'max') {
return HttpResponse.json(
{
success: false,
error: '很抱歉,「价小前投研」功能仅对 Max 订阅用户开放。请升级您的订阅以使用此功能。',
},
{ status: 403 }
);
}
// 生成或使用现有 session_id
const responseSessionId = session_id || `session-${Date.now()}`;
// 根据消息内容生成响应
const response = generateAgentResponse(message, responseSessionId);
return HttpResponse.json({
success: true,
message: '处理成功',
session_id: responseSessionId,
plan: response.plan,
steps: response.step_results,
final_answer: response.final_summary,
metadata: {
model: body.model || 'kimi-k2-thinking',
timestamp: new Date().toISOString(),
},
});
}),
// GET /mcp/agent/sessions - 获取会话列表
http.get('/mcp/agent/sessions', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const userId = url.searchParams.get('user_id');
const limit = parseInt(url.searchParams.get('limit') || '50');
// 返回模拟的会话列表
const sessions = mockSessions.slice(0, limit);
return HttpResponse.json({
success: true,
data: sessions,
count: sessions.length,
});
}),
// GET /mcp/agent/history/:session_id - 获取会话历史
http.get('/mcp/agent/history/:session_id', async ({ params }) => {
await delay(400);
const { session_id } = params;
// 返回模拟的历史消息
const history = mockHistory[session_id] || [];
return HttpResponse.json({
success: true,
data: history,
count: history.length,
});
}),
];

View File

@@ -15,6 +15,7 @@ import { financialHandlers } from './financial';
import { limitAnalyseHandlers } from './limitAnalyse';
import { posthogHandlers } from './posthog';
import { externalHandlers } from './external';
import { agentHandlers } from './agent';
// 可以在这里添加更多的 handlers
// import { userHandlers } from './user';
@@ -34,5 +35,6 @@ export const handlers = [
...limitAnalyseHandlers,
...posthogHandlers,
...externalHandlers,
...agentHandlers,
// ...userHandlers,
];

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() {

View File

@@ -6,6 +6,7 @@ import industryReducer from './slices/industrySlice';
import stockReducer from './slices/stockSlice';
import authModalReducer from './slices/authModalSlice';
import subscriptionReducer from './slices/subscriptionSlice';
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
import posthogMiddleware from './middleware/posthogMiddleware';
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
@@ -17,6 +18,7 @@ export const store = configureStore({
stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
},
middleware: (getDefaultMiddleware) =>

View File

@@ -2,7 +2,6 @@
// 认证弹窗状态管理 Redux Slice - 从 AuthModalContext 迁移
import { createSlice } from '@reduxjs/toolkit';
import { logger } from '../../utils/logger';
/**
* AuthModal Slice
@@ -22,9 +21,6 @@ const authModalSlice = createSlice({
openModal: (state, action) => {
state.isOpen = true;
state.redirectUrl = action.payload?.redirectUrl || null;
logger.debug('authModalSlice', '打开认证弹窗', {
redirectUrl: action.payload?.redirectUrl || '无'
});
},
/**
@@ -33,7 +29,6 @@ const authModalSlice = createSlice({
closeModal: (state) => {
state.isOpen = false;
state.redirectUrl = null;
logger.debug('authModalSlice', '关闭认证弹窗');
},
/**

View File

@@ -0,0 +1,52 @@
// src/store/slices/deviceSlice.js
import { createSlice } from '@reduxjs/toolkit';
/**
* 检测当前设备是否为移动设备
*
* 判断逻辑:
* 1. User Agent 检测(移动设备标识)
* 2. 屏幕宽度检测(<= 768px
* 3. 触摸屏检测(支持触摸事件)
*
* @returns {boolean} true 表示移动设备false 表示桌面设备
*/
const detectIsMobile = () => {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
const isMobileUA = mobileRegex.test(userAgent);
const isMobileWidth = window.innerWidth <= 768;
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
return isMobileUA || (isMobileWidth && hasTouchScreen);
};
const initialState = {
isMobile: detectIsMobile(),
};
const deviceSlice = createSlice({
name: 'device',
initialState,
reducers: {
/**
* 更新屏幕尺寸状态
*
* 使用场景:
* - 监听 window resize 事件时调用
* - 屏幕方向变化时调用orientationchange
*/
updateScreenSize: (state) => {
state.isMobile = detectIsMobile();
},
},
});
// Actions
export const { updateScreenSize } = deviceSlice.actions;
// Selectors
export const selectIsMobile = (state) => state.device.isMobile;
// Reducer
export default deviceSlice.reducer;

View File

@@ -0,0 +1,63 @@
/**
* deviceSlice 单元测试
*
* 测试用例:
* 1. 初始状态检查
* 2. updateScreenSize action 测试
* 3. selector 函数测试
*/
import deviceReducer, { updateScreenSize, selectIsMobile } from './deviceSlice';
describe('deviceSlice', () => {
describe('reducer', () => {
it('should return the initial state', () => {
const initialState = deviceReducer(undefined, { type: '@@INIT' });
expect(initialState).toHaveProperty('isMobile');
expect(typeof initialState.isMobile).toBe('boolean');
});
it('should handle updateScreenSize', () => {
// 模拟初始状态
const initialState = { isMobile: false };
// 执行 action注意实际 isMobile 值由 detectIsMobile() 决定)
const newState = deviceReducer(initialState, updateScreenSize());
// 验证状态结构
expect(newState).toHaveProperty('isMobile');
expect(typeof newState.isMobile).toBe('boolean');
});
});
describe('selectors', () => {
it('selectIsMobile should return correct value', () => {
const mockState = {
device: {
isMobile: true,
},
};
const result = selectIsMobile(mockState);
expect(result).toBe(true);
});
it('selectIsMobile should return false for desktop', () => {
const mockState = {
device: {
isMobile: false,
},
};
const result = selectIsMobile(mockState);
expect(result).toBe(false);
});
});
describe('actions', () => {
it('updateScreenSize action should have correct type', () => {
const action = updateScreenSize();
expect(action.type).toBe('device/updateScreenSize');
});
});
});

View File

@@ -0,0 +1,190 @@
/**
* deviceSlice 使用示例
*
* 本文件展示如何在 React 组件中使用 deviceSlice 来实现响应式设计
*/
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectIsMobile, updateScreenSize } from '@/store/slices/deviceSlice';
import { Box, Text, VStack } from '@chakra-ui/react';
/**
* 示例 1: 基础使用 - 根据设备类型渲染不同内容
*/
export const BasicUsageExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box>
{isMobile ? (
<Text>📱 移动端视图</Text>
) : (
<Text>💻 桌面端视图</Text>
)}
</Box>
);
};
/**
* 示例 2: 监听窗口尺寸变化 - 动态更新设备状态
*/
export const ResizeListenerExample = () => {
const isMobile = useSelector(selectIsMobile);
const dispatch = useDispatch();
useEffect(() => {
// 监听窗口尺寸变化
const handleResize = () => {
dispatch(updateScreenSize());
};
// 监听屏幕方向变化(移动设备)
const handleOrientationChange = () => {
dispatch(updateScreenSize());
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleOrientationChange);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleOrientationChange);
};
}, [dispatch]);
return (
<VStack>
<Text>当前设备: {isMobile ? '移动设备' : '桌面设备'}</Text>
<Text fontSize="sm" color="gray.500">
试试调整浏览器窗口大小
</Text>
</VStack>
);
};
/**
* 示例 3: 响应式布局 - 根据设备类型调整样式
*/
export const ResponsiveLayoutExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box
p={isMobile ? 4 : 8}
bg={isMobile ? 'blue.50' : 'gray.50'}
borderRadius={isMobile ? 'md' : 'xl'}
maxW={isMobile ? '100%' : '800px'}
mx="auto"
>
<Text fontSize={isMobile ? 'md' : 'lg'}>
响应式内容区域
</Text>
<Text fontSize="sm" color="gray.600" mt={2}>
Padding: {isMobile ? '16px' : '32px'}
</Text>
</Box>
);
};
/**
* 示例 4: 条件渲染组件 - 移动端显示简化版
*/
export const ConditionalRenderExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box>
{isMobile ? (
// 移动端:简化版导航栏
<Box bg="blue.500" p={2}>
<Text color="white" fontSize="sm"> 菜单</Text>
</Box>
) : (
// 桌面端:完整导航栏
<Box bg="blue.500" p={4}>
<Text color="white" fontSize="lg">
首页 | 产品 | 关于我们 | 联系方式
</Text>
</Box>
)}
</Box>
);
};
/**
* 示例 5: 在 App.js 中全局监听(推荐方式)
*
* 将以下代码添加到 src/App.js 中:
*/
export const AppLevelResizeListenerExample = () => {
const dispatch = useDispatch();
useEffect(() => {
const handleResize = () => {
dispatch(updateScreenSize());
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
// 初始化时也调用一次(可选)
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
};
}, [dispatch]);
// 返回 null 或组件内容
return null;
};
/**
* 示例 6: 自定义 Hook 封装(推荐)
*
* 在 src/hooks/useDevice.js 中创建自定义 Hook
*/
// import { useSelector } from 'react-redux';
// import { selectIsMobile } from '@/store/slices/deviceSlice';
//
// export const useDevice = () => {
// const isMobile = useSelector(selectIsMobile);
//
// return {
// isMobile,
// isDesktop: !isMobile,
// };
// };
/**
* 使用自定义 Hook
*/
export const CustomHookUsageExample = () => {
// const { isMobile, isDesktop } = useDevice();
return (
<Box>
{/* <Text>移动设备: {isMobile ? '是' : '否'}</Text> */}
{/* <Text>桌面设备: {isDesktop ? '是' : '否'}</Text> */}
</Box>
);
};
/**
* 推荐实践:
*
* 1. 在 App.js 中添加全局 resize 监听器
* 2. 创建自定义 Hook (useDevice) 简化使用
* 3. 结合 Chakra UI 的响应式 Props优先使用 Chakra 内置响应式)
* 4. 仅在需要 JS 逻辑判断时使用 Redux如条件渲染、动态导入
*
* Chakra UI 响应式示例(推荐优先使用):
* <Box
* fontSize={{ base: 'sm', md: 'md', lg: 'lg' }} // Chakra 内置响应式
* p={{ base: 4, md: 6, lg: 8 }}
* >
* 内容
* </Box>
*/

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,393 @@
// src/utils/performanceMonitor.ts
// 性能监控工具 - 统计白屏时间和性能指标
import { logger } from './logger';
/**
* 性能指标接口
*/
export interface PerformanceMetrics {
// 网络指标
dns?: number; // DNS查询时间
tcp?: number; // TCP连接时间
ttfb?: number; // 首字节时间(Time To First Byte)
domLoad?: number; // DOM加载时间
resourceLoad?: number; // 资源加载时间
// 渲染指标
fp?: number; // 首次绘制(First Paint)
fcp?: number; // 首次内容绘制(First Contentful Paint)
lcp?: number; // 最大内容绘制(Largest Contentful Paint)
// 自定义指标
reactInit?: number; // React初始化时间
authCheck?: number; // 认证检查时间
homepageRender?: number; // 首页渲染时间
// 总计
totalWhiteScreen?: number; // 总白屏时间
}
/**
* 性能报告接口
*/
export interface PerformanceReport {
summary: {
performanceScore: string;
totalMarks: number;
totalMeasures: number;
};
metrics: PerformanceMetrics;
recommendations: string[];
marks: Array<{ name: string; time: number }>;
measures: Array<{ name: string; duration: number; startMark: string; endMark: string }>;
}
/**
* 性能时间点记录
*/
const performanceMarks: Map<string, number> = new Map();
/**
* 性能测量记录
*/
const performanceMeasures: Array<{ name: string; duration: number; startMark: string; endMark: string }> = [];
/**
* 性能监控器类
*/
class PerformanceMonitor {
private metrics: PerformanceMetrics = {};
private isProduction: boolean;
constructor() {
this.isProduction = process.env.NODE_ENV === 'production';
}
/**
* 标记性能时间点
*/
mark(name: string): void {
const timestamp = performance.now();
performanceMarks.set(name, timestamp);
if (!this.isProduction) {
logger.debug('PerformanceMonitor', `⏱️ Mark: ${name}`, {
time: `${timestamp.toFixed(2)}ms`
});
}
}
/**
* 计算两个时间点之间的耗时
*/
measure(startMark: string, endMark: string, name?: string): number | null {
const startTime = performanceMarks.get(startMark);
const endTime = performanceMarks.get(endMark);
if (!startTime || !endTime) {
logger.warn('PerformanceMonitor', 'Missing performance mark', {
startMark,
endMark,
hasStart: !!startTime,
hasEnd: !!endTime
});
return null;
}
const duration = endTime - startTime;
const measureName = name || `${startMark}${endMark}`;
// 记录测量
performanceMeasures.push({
name: measureName,
duration,
startMark,
endMark
});
if (!this.isProduction) {
logger.debug('PerformanceMonitor', `📊 Measure: ${measureName}`, {
duration: `${duration.toFixed(2)}ms`
});
}
return duration;
}
/**
* 获取浏览器性能指标
*/
collectBrowserMetrics(): void {
if (!window.performance || !window.performance.timing) {
logger.warn('PerformanceMonitor', 'Performance API not supported');
return;
}
const timing = window.performance.timing;
const navigationStart = timing.navigationStart;
// 网络指标
this.metrics.dns = timing.domainLookupEnd - timing.domainLookupStart;
this.metrics.tcp = timing.connectEnd - timing.connectStart;
this.metrics.ttfb = timing.responseStart - navigationStart;
this.metrics.domLoad = timing.domContentLoadedEventEnd - navigationStart;
this.metrics.resourceLoad = timing.loadEventEnd - navigationStart;
// 获取 FP/FCP/LCP
this.collectPaintMetrics();
}
/**
* 收集绘制指标(FP/FCP/LCP)
*/
private collectPaintMetrics(): void {
if (!window.performance || !window.performance.getEntriesByType) {
return;
}
// First Paint & First Contentful Paint
const paintEntries = performance.getEntriesByType('paint');
paintEntries.forEach((entry: any) => {
if (entry.name === 'first-paint') {
this.metrics.fp = entry.startTime;
} else if (entry.name === 'first-contentful-paint') {
this.metrics.fcp = entry.startTime;
}
});
// Largest Contentful Paint (需要 PerformanceObserver)
try {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1] as any;
this.metrics.lcp = lastEntry.startTime;
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });
} catch (e) {
// LCP可能不被支持
}
}
/**
* 收集自定义React指标
*/
collectReactMetrics(): void {
// React初始化时间
const reactInit = this.measure('app-start', 'react-ready', 'React初始化');
if (reactInit) this.metrics.reactInit = reactInit;
// 认证检查时间
const authCheck = this.measure('auth-check-start', 'auth-check-end', '认证检查');
if (authCheck) this.metrics.authCheck = authCheck;
// 首页渲染时间
const homepageRender = this.measure('homepage-render-start', 'homepage-render-end', '首页渲染');
if (homepageRender) this.metrics.homepageRender = homepageRender;
// 计算总白屏时间 (从页面开始到首屏完成)
const totalWhiteScreen = this.measure('app-start', 'homepage-render-end', '总白屏时间');
if (totalWhiteScreen) this.metrics.totalWhiteScreen = totalWhiteScreen;
}
/**
* 生成性能报告(控制台版本)
*/
generateReport(): PerformanceMetrics {
this.collectBrowserMetrics();
this.collectReactMetrics();
const report = {
'=== 网络阶段 ===': {
'DNS查询': this.formatMs(this.metrics.dns),
'TCP连接': this.formatMs(this.metrics.tcp),
'首字节时间(TTFB)': this.formatMs(this.metrics.ttfb),
'DOM加载': this.formatMs(this.metrics.domLoad),
'资源加载': this.formatMs(this.metrics.resourceLoad),
},
'=== 渲染阶段 ===': {
'首次绘制(FP)': this.formatMs(this.metrics.fp),
'首次内容绘制(FCP)': this.formatMs(this.metrics.fcp),
'最大内容绘制(LCP)': this.formatMs(this.metrics.lcp),
},
'=== React阶段 ===': {
'React初始化': this.formatMs(this.metrics.reactInit),
'认证检查': this.formatMs(this.metrics.authCheck),
'首页渲染': this.formatMs(this.metrics.homepageRender),
},
'=== 总计 ===': {
'总白屏时间': this.formatMs(this.metrics.totalWhiteScreen),
}
};
logger.info('PerformanceMonitor', '🚀 性能报告', report);
// 性能分析建议
this.analyzePerformance();
return this.metrics;
}
/**
* 获取完整报告UI版本
*/
getReport(): PerformanceReport {
this.collectBrowserMetrics();
this.collectReactMetrics();
const recommendations = this.getRecommendations();
const performanceScore = this.calculatePerformanceScore();
return {
summary: {
performanceScore,
totalMarks: performanceMarks.size,
totalMeasures: performanceMeasures.length,
},
metrics: this.metrics,
recommendations,
marks: Array.from(performanceMarks.entries()).map(([name, time]) => ({ name, time })),
measures: performanceMeasures,
};
}
/**
* 计算性能评分
*/
private calculatePerformanceScore(): string {
const { totalWhiteScreen, ttfb, lcp } = this.metrics;
let score = 0;
let total = 0;
if (totalWhiteScreen !== undefined) {
total += 1;
if (totalWhiteScreen < 1500) score += 1;
else if (totalWhiteScreen < 2000) score += 0.7;
else if (totalWhiteScreen < 3000) score += 0.4;
}
if (ttfb !== undefined) {
total += 1;
if (ttfb < 500) score += 1;
else if (ttfb < 1000) score += 0.7;
else if (ttfb < 1500) score += 0.4;
}
if (lcp !== undefined) {
total += 1;
if (lcp < 2500) score += 1;
else if (lcp < 4000) score += 0.7;
else if (lcp < 6000) score += 0.4;
}
if (total === 0) return 'unknown';
const percentage = score / total;
if (percentage >= 0.9) return 'excellent';
if (percentage >= 0.7) return 'good';
if (percentage >= 0.5) return 'needs improvement';
return 'poor';
}
/**
* 获取优化建议
*/
private getRecommendations(): string[] {
const issues: string[] = [];
if (this.metrics.ttfb && this.metrics.ttfb > 500) {
issues.push(`TTFB过高(${this.metrics.ttfb.toFixed(0)}ms) - 建议优化服务器响应`);
}
if (this.metrics.resourceLoad && this.metrics.resourceLoad > 3000) {
issues.push(`资源加载慢(${this.metrics.resourceLoad.toFixed(0)}ms) - 建议代码分割/CDN`);
}
if (this.metrics.authCheck && this.metrics.authCheck > 300) {
issues.push(`认证检查慢(${this.metrics.authCheck.toFixed(0)}ms) - 建议优化Session API`);
}
if (this.metrics.totalWhiteScreen && this.metrics.totalWhiteScreen > 2000) {
issues.push(`白屏时间过长(${this.metrics.totalWhiteScreen.toFixed(0)}ms) - 目标 <1500ms`);
}
if (this.metrics.lcp && this.metrics.lcp > 2500) {
issues.push(`LCP过高(${this.metrics.lcp.toFixed(0)}ms) - 建议优化最大内容渲染`);
}
if (this.metrics.fcp && this.metrics.fcp > 1800) {
issues.push(`FCP过高(${this.metrics.fcp.toFixed(0)}ms) - 建议优化首屏内容渲染`);
}
if (issues.length === 0) {
issues.push('性能表现良好,无需优化');
}
return issues;
}
/**
* 性能分析和建议
*/
private analyzePerformance(): void {
const issues = this.getRecommendations();
if (issues.length > 0 && issues[0] !== '性能表现良好,无需优化') {
logger.warn('PerformanceMonitor', '🔴 性能问题', { issues });
} else {
logger.info('PerformanceMonitor', '✅ 性能良好');
}
}
/**
* 格式化毫秒数
*/
private formatMs(ms: number | undefined): string {
if (ms === undefined) return 'N/A';
let emoji = '✅';
if (ms > 1000) emoji = '❌';
else if (ms > 500) emoji = '⚠️';
return `${ms.toFixed(0)}ms ${emoji}`;
}
/**
* 导出 JSON
*/
exportJSON(): string {
const report = this.getReport();
return JSON.stringify(report, null, 2);
}
/**
* 获取所有指标
*/
getMetrics(): PerformanceMetrics {
return this.metrics;
}
/**
* 重置监控器
*/
reset(): void {
this.metrics = {};
performanceMarks.clear();
performanceMeasures.length = 0;
}
}
// 导出单例
export const performanceMonitor = new PerformanceMonitor();
// 页面加载完成后自动生成报告
if (typeof window !== 'undefined') {
window.addEventListener('load', () => {
// 延迟1秒确保所有指标收集完成
setTimeout(() => {
performanceMonitor.generateReport();
}, 1000);
});
}

View File

@@ -1,59 +0,0 @@
// src/views/AgentChat/components/BackgroundEffects.js
// 背景渐变装饰层组件
import React from 'react';
import { Box } from '@chakra-ui/react';
/**
* BackgroundEffects - 背景渐变装饰层
*
* 包含主背景渐变和两个装饰光效(右上紫色、左下蓝色)
*
* @returns {JSX.Element}
*/
const BackgroundEffects = () => {
return (
<>
{/* 主背景渐变层 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bgGradient="linear(to-br, gray.900, gray.800, purple.900)"
zIndex={0}
/>
{/* 右上角紫色光效 */}
<Box
position="absolute"
top="0"
right="-20%"
width="600px"
height="600px"
bgGradient="radial(circle, purple.600, transparent)"
opacity="0.15"
filter="blur(100px)"
pointerEvents="none"
zIndex={0}
/>
{/* 左下角蓝色光效 */}
<Box
position="absolute"
bottom="-30%"
left="-10%"
width="500px"
height="500px"
bgGradient="radial(circle, blue.600, transparent)"
opacity="0.1"
filter="blur(100px)"
pointerEvents="none"
zIndex={0}
/>
</>
);
};
export default BackgroundEffects;

View File

@@ -1,7 +1,7 @@
// src/views/AgentChat/components/ChatArea/index.js
// 中间聊天区域组件
import React from 'react';
import React, { useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
@@ -56,7 +56,6 @@ import MessageRenderer from './MessageRenderer';
* @param {Function} props.onToggleRightSidebar - 切换右侧栏回调
* @param {Function} props.onNewSession - 新建会话回调
* @param {string} props.userAvatar - 用户头像 URL
* @param {RefObject} props.messagesEndRef - 消息列表底部引用
* @param {RefObject} props.inputRef - 输入框引用
* @param {RefObject} props.fileInputRef - 文件上传输入引用
* @returns {JSX.Element}
@@ -78,10 +77,15 @@ const ChatArea = ({
onToggleRightSidebar,
onNewSession,
userAvatar,
messagesEndRef,
inputRef,
fileInputRef,
}) => {
// Auto-scroll 功能:当消息列表更新时,自动滚动到底部
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<Flex flex={1} direction="column">
{/* 顶部标题栏 - 深色毛玻璃 */}
@@ -214,7 +218,6 @@ const ChatArea = ({
{/* 消息列表 */}
<Box
flex={1}
p={6}
bgGradient="linear(to-b, rgba(17, 24, 39, 0.5), rgba(17, 24, 39, 0.3))"
overflowY="auto"
>
@@ -253,7 +256,7 @@ const ChatArea = ({
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<Box px={6} py={3}>
<Box px={6}>
<Box maxW="896px" mx="auto">
<HStack fontSize="xs" color="gray.500" mb={2} fontWeight="medium" spacing={1}>
<Sparkles className="w-3 h-3" />
@@ -308,7 +311,7 @@ const ChatArea = ({
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
py={1}
boxShadow="0 -8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Box maxW="896px" mx="auto">

View File

@@ -71,6 +71,7 @@ const LeftSidebar = ({
>
<Box
w="320px"
h="100%"
display="flex"
flexDirection="column"
bg="rgba(17, 24, 39, 0.8)"

View File

@@ -0,0 +1,203 @@
// src/views/AgentChat/components/RightSidebar/ToolSelector.tsx
// 工具选择组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Button,
Badge,
Checkbox,
CheckboxGroup,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
HStack,
VStack,
Box,
Text,
} from '@chakra-ui/react';
import { MCP_TOOLS, TOOL_CATEGORIES } from '../../constants/tools';
/**
* ToolSelector 组件的 Props 类型
*/
interface ToolSelectorProps {
/** 已选工具 ID 列表 */
selectedTools: string[];
/** 工具选择变化回调 */
onToolsChange: (tools: string[]) => void;
}
/**
* ToolSelector - 工具选择组件
*
* 职责:
* 1. 按分类展示工具列表Accordion 手风琴)
* 2. 复选框选择/取消工具
* 3. 显示每个分类的已选/总数(如 "3/5"
* 4. 全选/清空按钮
*
* 设计特性:
* - 手风琴分类折叠
* - 悬停工具项右移 4px
* - 全选/清空按钮渐变色
* - 分类徽章显示选中数量
*/
const ToolSelector: React.FC<ToolSelectorProps> = ({ selectedTools, onToolsChange }) => {
/**
* 全选所有工具
*/
const handleSelectAll = () => {
onToolsChange(MCP_TOOLS.map((t) => t.id));
};
/**
* 清空所有选择
*/
const handleClearAll = () => {
onToolsChange([]);
};
return (
<>
{/* 工具分类手风琴 */}
<Accordion allowMultiple>
{Object.entries(TOOL_CATEGORIES).map(([category, tools], catIdx) => {
const selectedCount = tools.filter((t) => selectedTools.includes(t.id)).length;
const totalCount = tools.length;
return (
<motion.div
key={category}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: catIdx * 0.05 }}
>
<AccordionItem
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
borderRadius="lg"
mb={2}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
_hover={{
bg: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
>
{/* 手风琴标题 */}
<AccordionButton>
<HStack flex={1} justify="space-between" pr={2}>
<Text color="gray.100" fontSize="sm">
{category}
</Text>
<Badge
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
variant="subtle"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{selectedCount}/{totalCount}
</Badge>
</HStack>
<AccordionIcon color="gray.400" />
</AccordionButton>
{/* 手风琴内容 */}
<AccordionPanel pb={4}>
<CheckboxGroup value={selectedTools} onChange={onToolsChange}>
<VStack align="stretch" spacing={2}>
{tools.map((tool) => (
<motion.div
key={tool.id}
whileHover={{ x: 4 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<Checkbox
value={tool.id}
colorScheme="purple"
p={2}
borderRadius="lg"
bg="rgba(255, 255, 255, 0.02)"
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
transition="background 0.2s"
>
<HStack spacing={2} align="start">
{/* 工具图标 */}
<Box color="purple.400" mt={0.5}>
{tool.icon}
</Box>
{/* 工具信息 */}
<Box>
<Text fontSize="sm" color="gray.200">
{tool.name}
</Text>
<Text fontSize="xs" color="gray.500">
{tool.description}
</Text>
</Box>
</HStack>
</Checkbox>
</motion.div>
))}
</VStack>
</CheckboxGroup>
</AccordionPanel>
</AccordionItem>
</motion.div>
);
})}
</Accordion>
{/* 全选/清空按钮 */}
<HStack mt={4} spacing={2}>
{/* 全选按钮 */}
<Box flex={1}>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
w="full"
variant="ghost"
onClick={handleSelectAll}
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
_hover={{
bgGradient: 'linear(to-r, blue.600, purple.600)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.4)',
}}
>
</Button>
</motion.div>
</Box>
{/* 清空按钮 */}
<Box flex={1}>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
w="full"
variant="ghost"
onClick={handleClearAll}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
>
</Button>
</motion.div>
</Box>
</HStack>
</>
);
};
export default ToolSelector;

View File

@@ -78,6 +78,7 @@ const RightSidebar = ({
>
<Box
w="320px"
h="100%"
display="flex"
flexDirection="column"
bg="rgba(17, 24, 39, 0.8)"

View File

@@ -2,8 +2,8 @@
// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本
// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果
import React, { useState, useEffect } from 'react';
import { Box, Flex, useToast, useColorMode } from '@chakra-ui/react';
import React, { useState } from 'react';
import { Box, Flex, useToast } from '@chakra-ui/react';
import { useAuth } from '@contexts/AuthContext';
// 常量配置 - 从 TypeScript 模块导入
@@ -11,13 +11,12 @@ import { DEFAULT_MODEL_ID } from './constants/models';
import { DEFAULT_SELECTED_TOOLS } from './constants/tools';
// 拆分后的子组件
import BackgroundEffects from './components/BackgroundEffects';
import LeftSidebar from './components/LeftSidebar';
import ChatArea from './components/ChatArea';
import RightSidebar from './components/RightSidebar';
// 自定义 Hooks
import { useAgentSessions, useAgentChat, useFileUpload, useAutoScroll } from './hooks';
import { useAgentSessions, useAgentChat, useFileUpload } from './hooks';
/**
* Agent Chat - 主组件HeroUI v3 深色主题)
@@ -35,7 +34,6 @@ import { useAgentSessions, useAgentChat, useFileUpload, useAutoScroll } from './
const AgentChat = () => {
const { user } = useAuth();
const toast = useToast();
const { setColorMode } = useColorMode();
// ==================== UI 状态(主组件管理)====================
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID);
@@ -83,79 +81,58 @@ const AgentChat = () => {
loadSessions,
});
// 自动滚动 Hook
const { messagesEndRef } = useAutoScroll(messages);
// ==================== 输入框引用(保留在主组件)====================
const inputRef = React.useRef(null);
// ==================== 启用深色模式 ====================
useEffect(() => {
// 为 AgentChat 页面强制启用深色模式
setColorMode('dark');
document.documentElement.classList.add('dark');
return () => {
// 组件卸载时不移除,让其他页面自己控制
// document.documentElement.classList.remove('dark');
};
}, [setColorMode]);
// ==================== 渲染组件 ====================
return (
<Box flex={1} bg="gray.900">
<Flex h="100%" overflow="hidden" position="relative">
{/* 背景渐变装饰 */}
<BackgroundEffects />
<Flex h="100%" position="relative" bg="gray.900">
{/* 左侧栏 */}
<LeftSidebar
isOpen={isLeftSidebarOpen}
onClose={() => setIsLeftSidebarOpen(false)}
sessions={sessions}
currentSessionId={currentSessionId}
onSessionSwitch={switchSession}
onNewSession={createNewSession}
isLoadingSessions={isLoadingSessions}
user={user}
/>
{/* 左侧栏 */}
<LeftSidebar
isOpen={isLeftSidebarOpen}
onClose={() => setIsLeftSidebarOpen(false)}
sessions={sessions}
currentSessionId={currentSessionId}
onSessionSwitch={switchSession}
onNewSession={createNewSession}
isLoadingSessions={isLoadingSessions}
user={user}
/>
{/* 中间聊天区 */}
<ChatArea
messages={messages}
inputValue={inputValue}
onInputChange={setInputValue}
isProcessing={isProcessing}
onSendMessage={handleSendMessage}
onKeyPress={handleKeyPress}
uploadedFiles={uploadedFiles}
onFileSelect={handleFileSelect}
onFileRemove={removeFile}
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={() => setIsLeftSidebarOpen(true)}
onToggleRightSidebar={() => setIsRightSidebarOpen(true)}
onNewSession={createNewSession}
userAvatar={user?.avatar}
inputRef={inputRef}
fileInputRef={fileInputRef}
/>
{/* 中间聊天区 */}
<ChatArea
messages={messages}
inputValue={inputValue}
onInputChange={setInputValue}
isProcessing={isProcessing}
onSendMessage={handleSendMessage}
onKeyPress={handleKeyPress}
uploadedFiles={uploadedFiles}
onFileSelect={handleFileSelect}
onFileRemove={removeFile}
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={() => setIsLeftSidebarOpen(true)}
onToggleRightSidebar={() => setIsRightSidebarOpen(true)}
onNewSession={createNewSession}
userAvatar={user?.avatar}
messagesEndRef={messagesEndRef}
inputRef={inputRef}
fileInputRef={fileInputRef}
/>
{/* 右侧栏 */}
<RightSidebar
isOpen={isRightSidebarOpen}
onClose={() => setIsRightSidebarOpen(false)}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
selectedTools={selectedTools}
onToolsChange={setSelectedTools}
sessionsCount={sessions.length}
messagesCount={messages.length}
/>
</Flex>
</Box>
{/* 右侧栏 */}
<RightSidebar
isOpen={isRightSidebarOpen}
onClose={() => setIsRightSidebarOpen(false)}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
selectedTools={selectedTools}
onToolsChange={setSelectedTools}
sessionsCount={sessions.length}
messagesCount={messages.length}
/>
</Flex>
);
};

View File

@@ -18,7 +18,8 @@ import {
import { StarIcon } from '@chakra-ui/icons';
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart';
import StockChartModal from '../../../../components/StockChart/StockChartModal';
import TimelineChartModal from '../../../../components/StockChart/TimelineChartModal';
import KLineChartModal from '../../../../components/StockChart/KLineChartModal';
import CitedContent from '../../../../components/Citation/CitedContent';
import { getChangeColor } from '../../../../utils/colorUtils';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
@@ -51,8 +52,8 @@ const StockListItem = ({
const dividerColor = PROFESSIONAL_COLORS.border.default;
const [isDescExpanded, setIsDescExpanded] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalChartType, setModalChartType] = useState('timeline'); // 跟踪用户点击的图表类型
const [isTimelineModalOpen, setIsTimelineModalOpen] = useState(false);
const [isKLineModalOpen, setIsKLineModalOpen] = useState(false);
const handleViewDetail = () => {
const stockCode = stock.stock_code.split('.')[0];
@@ -204,8 +205,7 @@ const StockListItem = ({
bg="rgba(59, 130, 246, 0.1)"
onClick={(e) => {
e.stopPropagation();
setModalChartType('timeline'); // 设置为分时图
setIsModalOpen(true);
setIsTimelineModalOpen(true);
}}
cursor="pointer"
flexShrink={0}
@@ -247,8 +247,7 @@ const StockListItem = ({
bg="rgba(168, 85, 247, 0.1)"
onClick={(e) => {
e.stopPropagation();
setModalChartType('daily'); // 设置为日K线
setIsModalOpen(true);
setIsKLineModalOpen(true);
}}
cursor="pointer"
flexShrink={0}
@@ -380,15 +379,23 @@ const StockListItem = ({
</HStack>
</Box>
{/* 股票详情弹窗 - 未打开时不渲染 */}
{isModalOpen && (
<StockChartModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
{/* 分时图弹窗 */}
{isTimelineModalOpen && (
<TimelineChartModal
isOpen={isTimelineModalOpen}
onClose={() => setIsTimelineModalOpen(false)}
stock={stock}
eventTime={eventTime}
/>
)}
{/* K线图弹窗 */}
{isKLineModalOpen && (
<KLineChartModal
isOpen={isKLineModalOpen}
onClose={() => setIsKLineModalOpen(false)}
stock={stock}
eventTime={eventTime}
size="6xl"
initialChartType={modalChartType} // 传递用户点击的图表类型
/>
)}
</>

View File

@@ -11,7 +11,7 @@ import {
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
import { eventService, stockService } from '../../../services/eventService';
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
import { useSubscription } from '../../../hooks/useSubscription';
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
import CitationMark from '../../../components/Citation/CitationMark';
@@ -43,6 +43,7 @@ const InvestmentCalendar = () => {
const [stockQuotes, setStockQuotes] = useState({});
const [klineModalVisible, setKlineModalVisible] = useState(false);
const [selectedStock, setSelectedStock] = useState(null);
const [selectedEventTime, setSelectedEventTime] = useState(null); // 记录事件时间
const [followingIds, setFollowingIds] = useState([]); // 正在处理关注的事件ID列表
const [addingToWatchlist, setAddingToWatchlist] = useState({}); // 正在添加到自选的股票代码
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
@@ -263,12 +264,43 @@ const InvestmentCalendar = () => {
loadStockQuotes(sortedStocks, eventTime);
};
// 添加交易所后缀
const addExchangeSuffix = (code) => {
const sixDigitCode = getSixDigitCode(code);
// 如果已有后缀,直接返回
if (code.includes('.')) return code;
// 根据股票代码规则添加后缀
if (sixDigitCode.startsWith('6')) {
return `${sixDigitCode}.SH`; // 上海
} else if (sixDigitCode.startsWith('0') || sixDigitCode.startsWith('3')) {
return `${sixDigitCode}.SZ`; // 深圳
} else if (sixDigitCode.startsWith('688')) {
return `${sixDigitCode}.SH`; // 科创板
}
return sixDigitCode;
};
// 显示K线图
const showKline = (stock) => {
setSelectedStock({
code: getSixDigitCode(stock[0]), // 确保使用六位代码
name: stock[1]
const stockCode = addExchangeSuffix(stock[0]);
// 将 selectedDate 转换为 YYYY-MM-DD 格式日K线只需要日期不需要时间
const formattedEventTime = selectedDate ? selectedDate.format('YYYY-MM-DD') : null;
console.log('[InvestmentCalendar] 打开K线图:', {
originalCode: stock[0],
processedCode: stockCode,
stockName: stock[1],
selectedDate: selectedDate?.format('YYYY-MM-DD'),
formattedEventTime: formattedEventTime
});
setSelectedStock({
stock_code: stockCode, // 添加交易所后缀
stock_name: stock[1]
});
setSelectedEventTime(formattedEventTime);
setKlineModalVisible(true);
};
@@ -808,12 +840,18 @@ const InvestmentCalendar = () => {
)}
</Modal>
{/* K线图模态框 */}
{klineModalVisible && selectedStock && (
<StockChartAntdModal
open={klineModalVisible}
{/* K线图弹窗 */}
{selectedStock && (
<KLineChartModal
isOpen={klineModalVisible}
onClose={() => {
setKlineModalVisible(false);
setSelectedStock(null);
setSelectedEventTime(null);
}}
stock={selectedStock}
onCancel={() => setKlineModalVisible(false)}
eventTime={selectedEventTime}
size="5xl"
/>
)}

View File

@@ -1,814 +0,0 @@
import React, { useRef, useMemo, useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Particles from '@tsparticles/react';
import { loadSlim } from '@tsparticles/slim';
import {
Box,
Container,
Heading,
Text,
Button,
HStack,
VStack,
Badge,
Grid,
GridItem,
Stat,
StatLabel,
StatNumber,
Flex,
Tag,
useColorModeValue,
} from '@chakra-ui/react';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ComposedChart,
ReferenceLine,
ReferenceDot,
Cell,
} from 'recharts';
import { indexService } from '../../../services/eventService';
// 将后端分钟/分时数据转换为 Recharts 数据
const toLineSeries = (resp) => {
const arr = resp?.data || [];
return arr.map((d, i) => ({ time: d.time || i, value: d.price ?? d.close, volume: d.volume }));
};
// 提取昨日收盘价:优先使用最后一条记录的 prev_close否则回退到倒数第二条的 close
const getPrevClose = (resp) => {
const arr = resp?.data || [];
if (!arr.length) return null;
const last = arr[arr.length - 1] || {};
if (last.prev_close !== undefined && last.prev_close !== null && isFinite(Number(last.prev_close))) {
return Number(last.prev_close);
}
const idx = arr.length >= 2 ? arr.length - 2 : arr.length - 1;
const k = arr[idx] || {};
const candidate = k.close ?? k.c ?? k.price ?? null;
return candidate != null ? Number(candidate) : null;
};
// 组合图表组件(折线图 + 成交量柱状图)
const CombinedChart = ({ series, title, color = "#FFD700", basePrice = null }) => {
const [cursorIndex, setCursorIndex] = useState(0);
const cursorRef = useRef(0);
// 直接将光标设置到最后一个数据点,不再使用动画
useEffect(() => {
if (!series || series.length === 0) return;
// 直接设置到最后一个点
const lastIndex = series.length - 1;
cursorRef.current = lastIndex;
setCursorIndex(lastIndex);
}, [series && series.length]);
const yDomain = useMemo(() => {
if (!series || series.length === 0) return ['auto', 'auto'];
const values = series
.map((d) => d?.value)
.filter((v) => typeof v === 'number' && isFinite(v));
if (values.length === 0) return ['auto', 'auto'];
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const maxAbs = Math.max(Math.abs(minVal), Math.abs(maxVal));
const padding = Math.max(maxAbs * 0.1, 0.2);
return [-maxAbs - padding, maxAbs + padding];
}, [series]);
// 当前高亮点
const activePoint = useMemo(() => {
if (!series || series.length === 0) return null;
if (cursorIndex < 0 || cursorIndex >= series.length) return null;
return series[cursorIndex];
}, [series, cursorIndex]);
// 稳定的X轴ticks避免随渲染跳动而闪烁
const xTicks = useMemo(() => {
if (!series || series.length === 0) return [];
const desiredLabels = ['09:30', '10:30', '11:30', '14:00', '15:00'];
const set = new Set(series.map(d => d?.time));
let ticks = desiredLabels.filter(t => set.has(t));
if (ticks.length === 0) {
// 回退到首/中/尾的稳定采样,避免空白
const len = series.length;
const idxs = [0, Math.round(len * 0.25), Math.round(len * 0.5), Math.round(len * 0.75), len - 1];
ticks = idxs.map(i => series[i]?.time).filter(Boolean);
}
return ticks;
}, [series && series.length]);
return (
<Box h="full" position="relative">
<Text
fontSize="xs"
color={color}
fontFamily="monospace"
mb={1}
px={2}
>
{title}
</Text>
<ResponsiveContainer width="100%" height="90%">
<ComposedChart data={series} margin={{ top: 10, right: 10, left: 0, bottom: 30 }}>
<defs>
<linearGradient id={`gradient-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.8}/>
<stop offset="100%" stopColor={color} stopOpacity={0.2}/>
</linearGradient>
<linearGradient id={`barGradient-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3}/>
<stop offset="100%" stopColor={color} stopOpacity={0.05}/>
</linearGradient>
<linearGradient id={`barGradientActive-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.8}/>
<stop offset="100%" stopColor={color} stopOpacity={0.3}/>
</linearGradient>
{/* 发光效果 */}
<filter id={`glow-${title.replace(/[.\s]/g, '')}`}>
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255, 215, 0, 0.1)" />
<XAxis
dataKey="time"
stroke={color}
tick={{ fill: color, fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: `${color}33` }}
ticks={xTicks}
interval={0}
allowDuplicatedCategory={false}
/>
{/* 左Y轴 - 价格 */}
<YAxis
yAxisId="price"
stroke={color}
domain={yDomain}
tickFormatter={(v) => `${v.toFixed(2)}%`}
orientation="left"
/>
{/* 右Y轴 - 成交量(隐藏) */}
<YAxis
yAxisId="volume"
orientation="right"
hide
domain={[0, 'dataMax + 1000']}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(0,0,0,0.9)',
border: `1px solid ${color}`,
borderRadius: '8px'
}}
labelStyle={{ color: '#fff' }}
itemStyle={{ color: '#fff' }}
labelFormatter={(label) => `时间: ${label}`}
formatter={(value, name) => {
if (name === 'value') {
const pct = Number(value);
if (typeof basePrice === 'number' && isFinite(basePrice)) {
const price = basePrice * (1 + pct / 100);
return [price.toFixed(2), '价格'];
}
return [`${pct.toFixed(2)}%`, '涨跌幅'];
}
if (name === 'volume') return [`${(Number(value) / 100000000).toFixed(2)}亿`, '成交量'];
return [value, name];
}}
/>
{/* 零轴参考线 */}
<ReferenceLine yAxisId="price" y={0} stroke="#666" strokeDasharray="4 4" />
{/* 成交量柱状图 */}
<Bar
yAxisId="volume"
dataKey="volume"
fill={`url(#barGradient-${title.replace(/[.\s]/g, '')})`}
radius={[2, 2, 0, 0]}
isAnimationActive={false}
barSize={20}
>
{series.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={index <= cursorIndex ? `url(#barGradientActive-${title.replace(/[.\s]/g, '')})` : `url(#barGradient-${title.replace(/[.\s]/g, '')})`}
/>
))}
</Bar>
{/* 价格折线 */}
<Line
yAxisId="price"
isAnimationActive={false}
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
dot={false}
/>
{/* 移动的亮点 - 使用 ReferenceDot 贴合主数据坐标系 */}
{activePoint && (
<ReferenceDot
xAxisId={0}
yAxisId="price"
x={activePoint.time}
y={activePoint.value}
r={6}
isFront
ifOverflow="hidden"
shape={(props) => (
<g>
<circle
cx={props.cx}
cy={props.cy}
r={8}
fill={color}
stroke="#fff"
strokeWidth={2}
filter={`url(#glow-${title.replace(/[.\s]/g, '')})`}
/>
</g>
)}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</Box>
);
};
// 数据流动线条组件
function DataStreams() {
const lines = useMemo(() => {
return [...Array(15)].map((_, i) => ({
id: i,
startX: Math.random() * 100,
delay: Math.random() * 5,
duration: 3 + Math.random() * 2,
height: 30 + Math.random() * 70
}));
}, []);
return (
<Box position="absolute" inset={0} overflow="hidden" pointerEvents="none">
{lines.map((line) => (
<motion.div
key={line.id}
style={{
position: 'absolute',
width: '1px',
background: 'linear-gradient(to bottom, transparent, rgba(255, 215, 0, 0.3), transparent)',
left: `${line.startX}%`,
height: `${line.height}%`,
}}
initial={{ y: '-100%', opacity: 0 }}
animate={{
y: '200%',
opacity: [0, 0.5, 0.5, 0]
}}
transition={{
duration: line.duration,
delay: line.delay,
repeat: Infinity,
ease: "linear"
}}
/>
))}
</Box>
);
}
// 主组件
export default function MidjourneyHeroSection() {
const [sse, setSse] = useState({
sh: { data: [], base: null },
sz: { data: [], base: null },
cyb: { data: [], base: null }
});
useEffect(() => {
const fetchData = async () => {
try {
const [shTL, szTL, cybTL, shDaily, szDaily, cybDaily] = await Promise.all([
// 指数不传 event_time后端自动返回"最新可用"交易日
indexService.getKlineData('000001.SH', 'timeline'),
indexService.getKlineData('399001.SZ', 'timeline'),
indexService.getKlineData('399006.SZ', 'timeline'), // 创业板指
indexService.getKlineData('000001.SH', 'daily'),
indexService.getKlineData('399001.SZ', 'daily'),
indexService.getKlineData('399006.SZ', 'daily'),
]);
const shPrevClose = getPrevClose(shDaily);
const szPrevClose = getPrevClose(szDaily);
const cybPrevClose = getPrevClose(cybDaily);
const shSeries = toLineSeries(shTL);
const szSeries = toLineSeries(szTL);
const cybSeries = toLineSeries(cybTL);
const baseSh = (typeof shPrevClose === 'number' && isFinite(shPrevClose))
? shPrevClose
: (shSeries.length ? shSeries[0].value : 1);
const baseSz = (typeof szPrevClose === 'number' && isFinite(szPrevClose))
? szPrevClose
: (szSeries.length ? szSeries[0].value : 1);
const baseCyb = (typeof cybPrevClose === 'number' && isFinite(cybPrevClose))
? cybPrevClose
: (cybSeries.length ? cybSeries[0].value : 1);
const shPct = shSeries.map(p => ({
time: p.time,
value: ((p.value / baseSh) - 1) * 100,
volume: p.volume || 0
}));
const szPct = szSeries.map(p => ({
time: p.time,
value: ((p.value / baseSz) - 1) * 100,
volume: p.volume || 0
}));
const cybPct = cybSeries.map(p => ({
time: p.time,
value: ((p.value / baseCyb) - 1) * 100,
volume: p.volume || 0
}));
setSse({
sh: { data: shPct, base: baseSh },
sz: { data: szPct, base: baseSz },
cyb: { data: cybPct, base: baseCyb }
});
} catch (e) {
// ignore
}
};
fetchData();
}, []);
const particlesInit = async (engine) => {
await loadSlim(engine);
};
const particlesOptions = {
particles: {
number: {
value: 80,
density: {
enable: true,
value_area: 800
}
},
color: {
value: ["#FFD700", "#FF9800", "#FFC107", "#FFEB3B"]
},
shape: {
type: "circle"
},
opacity: {
value: 0.3,
random: true,
anim: {
enable: true,
speed: 1,
opacity_min: 0.1,
sync: false
}
},
size: {
value: 2,
random: true,
anim: {
enable: true,
speed: 2,
size_min: 0.1,
sync: false
}
},
line_linked: {
enable: true,
distance: 150,
color: "#FFD700",
opacity: 0.2,
width: 1
},
move: {
enable: true,
speed: 0.5,
direction: "none",
random: false,
straight: false,
out_mode: "out",
bounce: false,
}
},
interactivity: {
detect_on: "canvas",
events: {
onhover: {
enable: true,
mode: "grab"
},
onclick: {
enable: true,
mode: "push"
},
resize: true
},
modes: {
grab: {
distance: 140,
line_linked: {
opacity: 0.5
}
},
push: {
particles_nb: 4
}
}
},
retina_detect: true
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
ease: "easeOut"
}
}
};
return (
<Box
position="relative"
minH="100vh"
bg="linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #000000 100%)"
overflow="hidden"
pointerEvents="none"
>
{/* 粒子背景 */}
<Box position="absolute" inset={0} zIndex={-1} pointerEvents="none">
<Particles
id="tsparticles"
init={particlesInit}
options={particlesOptions}
style={{
position: 'absolute',
width: '100%',
height: '100%',
}}
/>
</Box>
{/* 数据流动效果 */}
<DataStreams />
{/* 内容容器 */}
<Container maxW="7xl" position="relative" zIndex={1} pt={20} pb={20}>
<Grid templateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }} gap={12} alignItems="center" minH="70vh">
{/* 左侧文本内容 */}
<GridItem>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<VStack align="start" spacing={6}>
{/* 标签 */}
<motion.div variants={itemVariants}>
<Badge
colorScheme="yellow"
variant="subtle"
px={4}
py={2}
borderRadius="full"
fontSize="sm"
fontFamily="monospace"
display="inline-flex"
alignItems="center"
>
<Box
as="span"
w={2}
h={2}
bg="yellow.400"
borderRadius="full"
mr={2}
animation="pulse 2s ease-in-out infinite"
/>
AI-Assisted Curation
</Badge>
</motion.div>
{/* 主标题 */}
<motion.div variants={itemVariants}>
<Heading
fontSize={{ base: '4xl', md: '5xl', lg: '6xl' }}
fontWeight="bold"
lineHeight="shorter"
>
<Text
as="span"
bgGradient="linear(to-r, yellow.400, orange.400, yellow.500)"
bgClip="text"
>
ME-Agent
</Text>
<br />
<Text as="span" color="white">
实时分析系统
</Text>
</Heading>
</motion.div>
{/* 副标题 */}
<motion.div variants={itemVariants}>
<Heading
as="h3"
fontSize="xl"
color="gray.300"
fontWeight="semibold"
>
基于微调版{' '}
<Text as="span" color="yellow.400" fontFamily="monospace">
deepseek-r1
</Text>{' '}
进行深度研究
</Heading>
</motion.div>
{/* 描述文本 */}
<motion.div variants={itemVariants}>
<Text
color="gray.400"
fontSize="md"
lineHeight="tall"
maxW="xl"
>
ME (Money Edge) 是一款以大模型为底座由资深分析师参与校准的信息辅助系统
专为金融研究与企业决策等场景设计系统侧重于多源信息的汇聚清洗与结构化整理
结合自主训练的领域知识图谱并配合专家人工复核与整合帮助用户高效获取相关线索与参考资料
</Text>
</motion.div>
{/* 特性标签 */}
<motion.div variants={itemVariants}>
<HStack spacing={3} flexWrap="wrap">
{['海量信息整理', '领域知识图谱', '分析师复核', '结构化呈现'].map((tag) => (
<Tag
key={tag}
size="md"
variant="subtle"
colorScheme="gray"
borderRadius="lg"
px={3}
py={1}
bg="gray.800"
color="gray.300"
borderWidth="1px"
borderColor="gray.600"
>
{tag}
</Tag>
))}
</HStack>
</motion.div>
{/* 按钮组 */}
<motion.div variants={itemVariants}>
<HStack spacing={4} pt={4}>
<Button
size="lg"
variant="outline"
color="gray.300"
borderColor="gray.600"
borderRadius="full"
px={8}
_hover={{
bg: "gray.800",
borderColor: "gray.500",
}}
transition="all 0.2s"
>
了解更多
</Button>
</HStack>
</motion.div>
{/* 统计数据 */}
<motion.div variants={itemVariants}>
<Grid
templateColumns="repeat(3, 1fr)"
gap={6}
pt={8}
borderTop="1px"
borderTopColor="gray.800"
w="full"
>
{[
{ label: '数据源', value: '10K+' },
{ label: '日处理', value: '1M+' },
{ label: '准确率', value: '98%' }
].map((stat) => (
<Stat key={stat.label}>
<StatNumber
fontSize="2xl"
fontWeight="bold"
color="yellow.400"
fontFamily="monospace"
>
{stat.value}
</StatNumber>
<StatLabel fontSize="sm" color="gray.500">
{stat.label}
</StatLabel>
</Stat>
))}
</Grid>
</motion.div>
</VStack>
</motion.div>
</GridItem>
{/* 右侧金融图表可视化 */}
<GridItem>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1, delay: 0.5 }}
>
<Box position="relative" h={{ base: '400px', md: '500px', lg: '600px' }}>
{/* 图表网格布局 */}
<Grid
templateColumns="repeat(2, 1fr)"
templateRows="repeat(2, 1fr)"
gap={4}
h="full"
p={4}
bg="rgba(0, 0, 0, 0.3)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 215, 0, 0.2)"
backdropFilter="blur(10px)"
>
{/* 上证指数 */}
<GridItem colSpan={2}>
<Box
h="full"
bg="rgba(0, 0, 0, 0.4)"
borderRadius="lg"
p={2}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.1)"
>
<CombinedChart
series={sse.sh?.data || []}
basePrice={sse.sh?.base}
title="000001.SH 上证指数"
color="#FFD700"
/>
</Box>
</GridItem>
{/* 深证成指 */}
<GridItem>
<Box
h="full"
bg="rgba(0, 0, 0, 0.4)"
borderRadius="lg"
p={2}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.1)"
>
<CombinedChart
series={sse.sz?.data || []}
basePrice={sse.sz?.base}
title="399001.SZ 深证成指"
color="#00E0FF"
/>
</Box>
</GridItem>
{/* 创业板指 */}
<GridItem>
<Box
h="full"
bg="rgba(0, 0, 0, 0.4)"
borderRadius="lg"
p={2}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.1)"
>
<CombinedChart
series={sse.cyb?.data || []}
basePrice={sse.cyb?.base}
title="399006.SZ 创业板指"
color="#FF69B4"
/>
</Box>
</GridItem>
</Grid>
{/* 装饰性光效 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="150%"
h="150%"
pointerEvents="none"
>
<Box
position="absolute"
top="20%"
left="20%"
w="200px"
h="200px"
bg="radial-gradient(circle, rgba(255, 215, 0, 0.15), transparent)"
borderRadius="full"
filter="blur(40px)"
animation="pulse 4s ease-in-out infinite"
/>
<Box
position="absolute"
bottom="20%"
right="20%"
w="150px"
h="150px"
bg="radial-gradient(circle, rgba(255, 152, 0, 0.15), transparent)"
borderRadius="full"
filter="blur(40px)"
animation="pulse 4s ease-in-out infinite"
sx={{ animationDelay: '2s' }}
/>
</Box>
</Box>
</motion.div>
</GridItem>
</Grid>
</Container>
{/* 底部渐变遮罩 */}
<Box
position="absolute"
bottom={0}
left={0}
right={0}
h="128px"
bgGradient="linear(to-t, black, transparent)"
zIndex={-1}
/>
{/* 全局样式 */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }
}
`}</style>
</Box>
);
}

View File

@@ -56,6 +56,8 @@ import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import TimelineChartModal from '../../../components/StockChart/TimelineChartModal';
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
import './InvestmentCalendar.css';
dayjs.locale('zh-cn');
@@ -63,6 +65,8 @@ dayjs.locale('zh-cn');
export default function InvestmentCalendarChakra() {
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
const { isOpen: isTimelineModalOpen, onOpen: onTimelineModalOpen, onClose: onTimelineModalClose } = useDisclosure();
const { isOpen: isKLineModalOpen, onOpen: onKLineModalOpen, onClose: onKLineModalClose } = useDisclosure();
const toast = useToast();
// 颜色主题
@@ -74,6 +78,7 @@ export default function InvestmentCalendarChakra() {
const [events, setEvents] = useState([]);
const [selectedDate, setSelectedDate] = useState(null);
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
const [selectedStock, setSelectedStock] = useState(null);
const [loading, setLoading] = useState(false);
const [newEvent, setNewEvent] = useState({
title: '',
@@ -262,6 +267,35 @@ export default function InvestmentCalendarChakra() {
}
};
// 处理股票点击 - 打开图表弹窗
const handleStockClick = (stockCodeOrName, eventDate) => {
// 解析股票代码(可能是 "600000" 或 "600000 平安银行" 格式)
let stockCode = stockCodeOrName;
let stockName = '';
if (typeof stockCodeOrName === 'string') {
const parts = stockCodeOrName.trim().split(/\s+/);
stockCode = parts[0];
stockName = parts.slice(1).join(' ');
}
// 添加交易所后缀(如果没有)
if (!stockCode.includes('.')) {
if (stockCode.startsWith('6')) {
stockCode = `${stockCode}.SH`;
} else if (stockCode.startsWith('0') || stockCode.startsWith('3')) {
stockCode = `${stockCode}.SZ`;
} else if (stockCode.startsWith('688')) {
stockCode = `${stockCode}.SH`;
}
}
setSelectedStock({
stock_code: stockCode,
stock_name: stockName || stockCode,
});
};
return (
<Card bg={bgColor} shadow="md">
<CardHeader pb={4}>
@@ -386,15 +420,47 @@ export default function InvestmentCalendarChakra() {
)}
{event.extendedProps?.stocks && event.extendedProps.stocks.length > 0 && (
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
{event.extendedProps.stocks.map((stock, i) => (
<Tag key={i} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</HStack>
<VStack align="stretch" spacing={2}>
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
{event.extendedProps.stocks.map((stock, i) => (
<Tag
key={i}
size="sm"
colorScheme="blue"
cursor="pointer"
onClick={() => handleStockClick(stock, event.start)}
_hover={{ transform: 'scale(1.05)', shadow: 'md' }}
transition="all 0.2s"
>
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</HStack>
{selectedStock && (
<HStack spacing={2}>
<Button
size="xs"
colorScheme="blue"
variant="outline"
leftIcon={<FiClock />}
onClick={onTimelineModalOpen}
>
分时图
</Button>
<Button
size="xs"
colorScheme="purple"
variant="outline"
leftIcon={<FiTrendingUp />}
onClick={onKLineModalOpen}
>
日K线
</Button>
</HStack>
)}
</VStack>
)}
</Box>
))}
@@ -489,6 +555,32 @@ export default function InvestmentCalendarChakra() {
</ModalContent>
</Modal>
)}
{/* 分时图弹窗 */}
{selectedStock && (
<TimelineChartModal
isOpen={isTimelineModalOpen}
onClose={() => {
onTimelineModalClose();
setSelectedStock(null);
}}
stock={selectedStock}
eventTime={selectedDate?.toISOString()}
/>
)}
{/* K线图弹窗 */}
{selectedStock && (
<KLineChartModal
isOpen={isKLineModalOpen}
onClose={() => {
onKLineModalClose();
setSelectedStock(null);
}}
stock={selectedStock}
eventTime={selectedDate?.toISOString()}
/>
)}
</Card>
);
}

View File

@@ -20,7 +20,6 @@ import { useNavigate } from 'react-router-dom';
import heroBg from '../../assets/img/BackgroundCard1.png';
import '../../styles/home-animations.css';
import { logger } from '../../utils/logger';
import MidjourneyHeroSection from '../Community/components/MidjourneyHeroSection';
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
import { ACQUISITION_EVENTS } from '../../lib/constants';
@@ -377,10 +376,6 @@ export default function HomePage() {
</SimpleGrid>
</VStack>
</Box>
{/* Midjourney风格英雄区域 */}
<MidjourneyHeroSection />
</VStack>
</Container>
</Box>

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;