Compare commits
58 Commits
5183473557
...
before-rec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be357a1c5 | ||
|
|
9f907b3cba | ||
|
|
bb878c5346 | ||
|
|
1bc3241596 | ||
|
|
cb46971e0e | ||
| 6679d99cf9 | |||
| 2c55a53c3a | |||
| 6ad56b9882 | |||
| b9eddbe752 | |||
|
|
cb9f927e3e | ||
|
|
b9a587bac4 | ||
|
|
86259793cb | ||
| f76bd17160 | |||
| ce0e91a5fb | |||
| f873fdb9a6 | |||
| cc446fc0da | |||
| de30755271 | |||
| a2f33c2a8a | |||
| 761fe5d2f0 | |||
| 3677217fce | |||
| 177c1d6401 | |||
| fb066aa6b8 | |||
| 96bedb8439 | |||
| 83d7c19fed | |||
| e80d2cfcec | |||
| 412f2a3d79 | |||
| 4a0e156bec | |||
| 7743a8a26a | |||
| 72e3e56a63 | |||
| 388e9eb235 | |||
| bd23100192 | |||
|
|
887525197a | ||
|
|
f8bb46ae64 | ||
| 810c878a1e | |||
| 2607028f4f | |||
| ea166d59c4 | |||
|
|
982d8135e7 | ||
|
|
e61090810b | ||
|
|
2d49af3bea | ||
|
|
3a0898634f | ||
|
|
44ecf7e5c7 | ||
|
|
baf4ca1ed4 | ||
|
|
3cd34d93c8 | ||
|
|
c9084ebb33 | ||
|
|
ed584b72d4 | ||
|
|
2dec587d37 | ||
|
|
7f021dcfa0 | ||
|
|
e34f5593b4 | ||
|
|
5f76530e80 | ||
|
|
d6c7d64e59 | ||
|
|
ceed71eca4 | ||
|
|
9669d5709e | ||
|
|
34bae35858 | ||
|
|
bc50d9fe3e | ||
|
|
39978c57d5 | ||
|
|
834067f679 | ||
|
|
e8b3d13c0a | ||
|
|
796c623197 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
129
app.py
@@ -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
|
||||
|
||||
@@ -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 处理
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
// src/components/Auth/AuthModalManager.js
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useBreakpointValue
|
||||
} from '@chakra-ui/react';
|
||||
import { Modal } from 'antd';
|
||||
import { useBreakpointValue } from '@chakra-ui/react';
|
||||
import { useAuthModal } from '../../hooks/useAuthModal';
|
||||
import AuthFormContent from './AuthFormContent';
|
||||
import { trackEventAsync } from '@lib/posthog';
|
||||
@@ -44,85 +38,43 @@ export default function AuthModalManager() {
|
||||
}
|
||||
}, [isAuthModalOpen]);
|
||||
|
||||
// 响应式尺寸配置
|
||||
const modalSize = useBreakpointValue({
|
||||
base: "md", // 移动端:md(不占满全屏)
|
||||
sm: "md", // 小屏:md
|
||||
md: "lg", // 中屏:lg
|
||||
lg: "xl" // 大屏:xl(更紧凑)
|
||||
});
|
||||
|
||||
// 响应式宽度配置
|
||||
const modalMaxW = useBreakpointValue({
|
||||
base: "90%", // 移动端:屏幕宽度的90%
|
||||
sm: "90%", // 小屏:90%
|
||||
md: "700px", // 中屏:固定700px
|
||||
lg: "700px" // 大屏:固定700px
|
||||
});
|
||||
|
||||
// 响应式水平边距
|
||||
const modalMx = useBreakpointValue({
|
||||
base: 4, // 移动端:左右各16px边距
|
||||
md: "auto" // 桌面端:自动居中
|
||||
});
|
||||
|
||||
// 响应式垂直边距
|
||||
const modalMy = useBreakpointValue({
|
||||
base: 8, // 移动端:上下各32px边距
|
||||
md: 8 // 桌面端:上下各32px边距
|
||||
});
|
||||
|
||||
// 条件渲染:只在打开时才渲染 Modal,避免创建不必要的 Portal
|
||||
if (!isAuthModalOpen) {
|
||||
return null;
|
||||
}
|
||||
// 响应式宽度配置(Ant Design Modal 使用数字或字符串)
|
||||
const modalMaxW = useBreakpointValue(
|
||||
{
|
||||
base: "90%", // 移动端:屏幕宽度的90%
|
||||
sm: "90%", // 小屏:90%
|
||||
md: "700px", // 中屏:固定700px
|
||||
lg: "700px" // 大屏:固定700px
|
||||
},
|
||||
{ fallback: "700px", ssr: false }
|
||||
);
|
||||
|
||||
// ✅ 使用 Ant Design Modal,完全避开 Chakra UI Portal 的 AnimatePresence 问题
|
||||
// Ant Design Modal 不使用 Framer Motion,不会有 React 18 并发渲染的 insertBefore 错误
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isAuthModalOpen}
|
||||
onClose={closeModal}
|
||||
size={modalSize}
|
||||
isCentered
|
||||
closeOnOverlayClick={false} // 防止误点击背景关闭
|
||||
closeOnEsc={true} // 允许ESC键关闭
|
||||
scrollBehavior="inside" // 内容滚动
|
||||
zIndex={999} // 低于导航栏(1000),不覆盖导航
|
||||
open={isAuthModalOpen}
|
||||
onCancel={closeModal}
|
||||
footer={null}
|
||||
width={modalMaxW}
|
||||
centered
|
||||
destroyOnHidden={true}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
zIndex={999}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
maxHeight: 'calc(90vh - 120px)',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
mask: {
|
||||
backdropFilter: 'blur(10px)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 半透明背景 + 模糊效果 */}
|
||||
<ModalOverlay
|
||||
bg="blackAlpha.700"
|
||||
backdropFilter="blur(10px)"
|
||||
/>
|
||||
|
||||
{/* 弹窗内容容器 */}
|
||||
<ModalContent
|
||||
bg="white"
|
||||
boxShadow="2xl"
|
||||
borderRadius="2xl"
|
||||
maxW={modalMaxW}
|
||||
mx={modalMx}
|
||||
my={modalMy}
|
||||
position="relative"
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<ModalCloseButton
|
||||
position="absolute"
|
||||
right={4}
|
||||
top={4}
|
||||
zIndex={9999}
|
||||
color="gray.500"
|
||||
bg="transparent"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
borderRadius="full"
|
||||
size="lg"
|
||||
onClick={closeModal}
|
||||
/>
|
||||
|
||||
{/* 弹窗主体内容 */}
|
||||
<ModalBody p={6}>
|
||||
<AuthFormContent />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
<AuthFormContent />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/GlobalComponents.js
|
||||
// 集中管理应用的全局组件
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -75,6 +75,9 @@ function ConnectionStatusBarWrapper() {
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
|
||||
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
|
||||
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Socket 连接状态条 */}
|
||||
@@ -89,9 +92,9 @@ export function GlobalComponents() {
|
||||
{/* 通知容器 */}
|
||||
<NotificationContainer />
|
||||
|
||||
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
|
||||
{/* Bytedesk在线客服 - 使用缓存的配置对象 */}
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
config={bytedeskConfigMemo}
|
||||
autoLoad={true}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -167,19 +167,8 @@ export default function HomeNavbar() {
|
||||
<BrandLogo />
|
||||
|
||||
{/* 中间导航区域 - 响应式 (Phase 4 优化) */}
|
||||
{isMobile ? (
|
||||
// 移动端:汉堡菜单
|
||||
<IconButton
|
||||
icon={<HamburgerIcon />}
|
||||
variant="ghost"
|
||||
onClick={onOpen}
|
||||
aria-label="Open menu"
|
||||
/>
|
||||
) : isTablet ? (
|
||||
// 中屏(平板):"更多"下拉菜单
|
||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||
) : (
|
||||
// 大屏(桌面):完整导航菜单
|
||||
{isDesktop && (
|
||||
// 桌面端:完整导航菜单(移动端和平板端的汉堡菜单已移至右侧)
|
||||
<DesktopNav isAuthenticated={isAuthenticated} user={user} />
|
||||
)}
|
||||
|
||||
@@ -189,6 +178,9 @@ export default function HomeNavbar() {
|
||||
isAuthenticated={isAuthenticated}
|
||||
user={user}
|
||||
isDesktop={isDesktop}
|
||||
isTablet={isTablet}
|
||||
isMobile={isMobile}
|
||||
onMenuOpen={onOpen}
|
||||
handleLogout={handleLogout}
|
||||
watchlistQuotes={watchlistQuotes}
|
||||
followingEvents={followingEvents}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
// Navbar 右侧功能区组件
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, Spinner } from '@chakra-ui/react';
|
||||
import { HStack, Spinner, IconButton, Box } from '@chakra-ui/react';
|
||||
import { HamburgerIcon } from '@chakra-ui/icons';
|
||||
// import ThemeToggleButton from '../ThemeToggleButton'; // ❌ 已删除 - 不再支持深色模式切换
|
||||
import LoginButton from '../LoginButton';
|
||||
import CalendarButton from '../CalendarButton';
|
||||
import { WatchlistMenu, FollowingEventsMenu } from '../FeatureMenus';
|
||||
import { DesktopUserMenu, TabletUserMenu } from '../UserMenu';
|
||||
import { PersonalCenterMenu } from '../Navigation';
|
||||
import { PersonalCenterMenu, MoreMenu } from '../Navigation';
|
||||
|
||||
/**
|
||||
* Navbar 右侧功能区组件
|
||||
@@ -18,6 +20,9 @@ import { PersonalCenterMenu } from '../Navigation';
|
||||
* @param {boolean} props.isAuthenticated - 是否已登录
|
||||
* @param {Object} props.user - 用户对象
|
||||
* @param {boolean} props.isDesktop - 是否为桌面端
|
||||
* @param {boolean} props.isTablet - 是否为平板端
|
||||
* @param {boolean} props.isMobile - 是否为移动端
|
||||
* @param {Function} props.onMenuOpen - 打开移动端抽屉菜单的回调
|
||||
* @param {Function} props.handleLogout - 登出回调
|
||||
* @param {Array} props.watchlistQuotes - 自选股数据(用于 TabletUserMenu)
|
||||
* @param {Array} props.followingEvents - 关注事件数据(用于 TabletUserMenu)
|
||||
@@ -27,6 +32,9 @@ const NavbarActions = memo(({
|
||||
isAuthenticated,
|
||||
user,
|
||||
isDesktop,
|
||||
isTablet,
|
||||
isMobile,
|
||||
onMenuOpen,
|
||||
handleLogout,
|
||||
watchlistQuotes,
|
||||
followingEvents
|
||||
@@ -60,13 +68,26 @@ const NavbarActions = memo(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 个人中心下拉菜单 - 仅大屏显示 */}
|
||||
{isDesktop && (
|
||||
{/* 头像右侧的菜单 - 响应式(互斥逻辑,确保只渲染一个) */}
|
||||
{isDesktop ? (
|
||||
// 桌面端:个人中心下拉菜单
|
||||
<PersonalCenterMenu user={user} handleLogout={handleLogout} />
|
||||
) : isTablet ? (
|
||||
// 平板端:MoreMenu 下拉菜单
|
||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||
) : (
|
||||
// 移动端:汉堡菜单(打开抽屉)
|
||||
<IconButton
|
||||
icon={<HamburgerIcon />}
|
||||
variant="ghost"
|
||||
onClick={onMenuOpen}
|
||||
aria-label="打开菜单"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
// 未登录状态 - 单一按钮
|
||||
// 未登录状态 - 仅显示登录按钮
|
||||
<LoginButton />
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
384
src/components/PerformancePanel.tsx
Normal file
384
src/components/PerformancePanel.tsx
Normal 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;
|
||||
614
src/components/StockChart/KLineChartModal.tsx
Normal file
614
src/components/StockChart/KLineChartModal.tsx
Normal 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;
|
||||
@@ -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' }}
|
||||
>
|
||||
|
||||
@@ -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={{}} />
|
||||
|
||||
514
src/components/StockChart/TimelineChartModal.tsx
Normal file
514
src/components/StockChart/TimelineChartModal.tsx
Normal 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;
|
||||
306
src/constants/performanceThresholds.js
Normal file
306
src/constants/performanceThresholds.js
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 性能指标阈值配置
|
||||
* 基于 Google Web Vitals 标准和项目实际情况
|
||||
*
|
||||
* 评级标准:
|
||||
* - good: 绿色,性能优秀
|
||||
* - needs-improvement: 黄色,需要改进
|
||||
* - poor: 红色,性能较差
|
||||
*
|
||||
* @see https://web.dev/defining-core-web-vitals-thresholds/
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Web Vitals 官方阈值(Google 标准)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Largest Contentful Paint (LCP) - 最大内容绘制
|
||||
* 衡量加载性能,理想情况下应在 2.5 秒内完成
|
||||
*/
|
||||
export const LCP_THRESHOLDS = {
|
||||
good: 2500, // < 2.5s 为优秀
|
||||
needsImprovement: 4000, // 2.5s - 4s 需要改进
|
||||
// > 4s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* First Contentful Paint (FCP) - 首次内容绘制
|
||||
* 衡量首次渲染任何内容的速度
|
||||
*/
|
||||
export const FCP_THRESHOLDS = {
|
||||
good: 1800, // < 1.8s 为优秀
|
||||
needsImprovement: 3000, // 1.8s - 3s 需要改进
|
||||
// > 3s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* Cumulative Layout Shift (CLS) - 累积布局偏移
|
||||
* 衡量视觉稳定性(无单位,分数值)
|
||||
*/
|
||||
export const CLS_THRESHOLDS = {
|
||||
good: 0.1, // < 0.1 为优秀
|
||||
needsImprovement: 0.25, // 0.1 - 0.25 需要改进
|
||||
// > 0.25 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* First Input Delay (FID) - 首次输入延迟
|
||||
* 衡量交互性能
|
||||
*/
|
||||
export const FID_THRESHOLDS = {
|
||||
good: 100, // < 100ms 为优秀
|
||||
needsImprovement: 300, // 100ms - 300ms 需要改进
|
||||
// > 300ms 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* Time to First Byte (TTFB) - 首字节时间
|
||||
* 衡量服务器响应速度
|
||||
*/
|
||||
export const TTFB_THRESHOLDS = {
|
||||
good: 800, // < 0.8s 为优秀
|
||||
needsImprovement: 1800, // 0.8s - 1.8s 需要改进
|
||||
// > 1.8s 为较差
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 自定义指标阈值(项目特定)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Time to Interactive (TTI) - 首屏可交互时间
|
||||
* 自定义指标:从页面加载到用户可以交互的时间
|
||||
*/
|
||||
export const TTI_THRESHOLDS = {
|
||||
good: 3500, // < 3.5s 为优秀
|
||||
needsImprovement: 7300, // 3.5s - 7.3s 需要改进
|
||||
// > 7.3s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* 骨架屏展示时长阈值
|
||||
*/
|
||||
export const SKELETON_DURATION_THRESHOLDS = {
|
||||
good: 300, // < 0.3s 为优秀(骨架屏展示时间短)
|
||||
needsImprovement: 1000, // 0.3s - 1s 需要改进
|
||||
// > 1s 为较差(骨架屏展示太久)
|
||||
};
|
||||
|
||||
/**
|
||||
* API 响应时间阈值
|
||||
*/
|
||||
export const API_RESPONSE_TIME_THRESHOLDS = {
|
||||
good: 500, // < 500ms 为优秀
|
||||
needsImprovement: 1500, // 500ms - 1.5s 需要改进
|
||||
// > 1.5s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源加载时间阈值
|
||||
*/
|
||||
export const RESOURCE_LOAD_TIME_THRESHOLDS = {
|
||||
good: 2000, // < 2s 为优秀
|
||||
needsImprovement: 5000, // 2s - 5s 需要改进
|
||||
// > 5s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundle 大小阈值(KB)
|
||||
*/
|
||||
export const BUNDLE_SIZE_THRESHOLDS = {
|
||||
js: {
|
||||
good: 500, // < 500KB 为优秀
|
||||
needsImprovement: 1000, // 500KB - 1MB 需要改进
|
||||
// > 1MB 为较差
|
||||
},
|
||||
css: {
|
||||
good: 100, // < 100KB 为优秀
|
||||
needsImprovement: 200, // 100KB - 200KB 需要改进
|
||||
// > 200KB 为较差
|
||||
},
|
||||
image: {
|
||||
good: 1500, // < 1.5MB 为优秀
|
||||
needsImprovement: 3000, // 1.5MB - 3MB 需要改进
|
||||
// > 3MB 为较差
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 缓存命中率阈值(百分比)
|
||||
*/
|
||||
export const CACHE_HIT_RATE_THRESHOLDS = {
|
||||
good: 80, // > 80% 为优秀
|
||||
needsImprovement: 50, // 50% - 80% 需要改进
|
||||
// < 50% 为较差
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 综合阈值配置对象
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 所有性能指标的阈值配置(用于类型化访问)
|
||||
*/
|
||||
export const PERFORMANCE_THRESHOLDS = {
|
||||
LCP: LCP_THRESHOLDS,
|
||||
FCP: FCP_THRESHOLDS,
|
||||
CLS: CLS_THRESHOLDS,
|
||||
FID: FID_THRESHOLDS,
|
||||
TTFB: TTFB_THRESHOLDS,
|
||||
TTI: TTI_THRESHOLDS,
|
||||
SKELETON_DURATION: SKELETON_DURATION_THRESHOLDS,
|
||||
API_RESPONSE_TIME: API_RESPONSE_TIME_THRESHOLDS,
|
||||
RESOURCE_LOAD_TIME: RESOURCE_LOAD_TIME_THRESHOLDS,
|
||||
BUNDLE_SIZE: BUNDLE_SIZE_THRESHOLDS,
|
||||
CACHE_HIT_RATE: CACHE_HIT_RATE_THRESHOLDS,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 根据指标值和阈值计算评级
|
||||
* @param {number} value - 指标值
|
||||
* @param {Object} thresholds - 阈值配置对象 { good, needsImprovement }
|
||||
* @param {boolean} reverse - 是否反向评级(值越大越好,如缓存命中率)
|
||||
* @returns {'good' | 'needs-improvement' | 'poor'}
|
||||
*/
|
||||
export const calculateRating = (value, thresholds, reverse = false) => {
|
||||
if (!thresholds || typeof value !== 'number') {
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
const { good, needsImprovement } = thresholds;
|
||||
|
||||
if (reverse) {
|
||||
// 反向评级:值越大越好(如缓存命中率)
|
||||
if (value >= good) return 'good';
|
||||
if (value >= needsImprovement) return 'needs-improvement';
|
||||
return 'poor';
|
||||
} else {
|
||||
// 正常评级:值越小越好(如加载时间)
|
||||
if (value <= good) return 'good';
|
||||
if (value <= needsImprovement) return 'needs-improvement';
|
||||
return 'poor';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评级对应的颜色(Chakra UI 颜色方案)
|
||||
* @param {'good' | 'needs-improvement' | 'poor'} rating
|
||||
* @returns {string} Chakra UI 颜色名称
|
||||
*/
|
||||
export const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good':
|
||||
return 'green';
|
||||
case 'needs-improvement':
|
||||
return 'yellow';
|
||||
case 'poor':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评级对应的控制台颜色代码
|
||||
* @param {'good' | 'needs-improvement' | 'poor'} rating
|
||||
* @returns {string} ANSI 颜色代码
|
||||
*/
|
||||
export const getRatingConsoleColor = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good':
|
||||
return '\x1b[32m'; // 绿色
|
||||
case 'needs-improvement':
|
||||
return '\x1b[33m'; // 黄色
|
||||
case 'poor':
|
||||
return '\x1b[31m'; // 红色
|
||||
default:
|
||||
return '\x1b[0m'; // 重置
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评级对应的图标
|
||||
* @param {'good' | 'needs-improvement' | 'poor'} rating
|
||||
* @returns {string} Emoji 图标
|
||||
*/
|
||||
export const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good':
|
||||
return '✅';
|
||||
case 'needs-improvement':
|
||||
return '⚠️';
|
||||
case 'poor':
|
||||
return '❌';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化指标值(添加单位)
|
||||
* @param {string} metricName - 指标名称
|
||||
* @param {number} value - 指标值
|
||||
* @returns {string} 格式化后的字符串
|
||||
*/
|
||||
export const formatMetricValue = (metricName, value) => {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
switch (metricName) {
|
||||
case 'LCP':
|
||||
case 'FCP':
|
||||
case 'FID':
|
||||
case 'TTFB':
|
||||
case 'TTI':
|
||||
case 'SKELETON_DURATION':
|
||||
case 'API_RESPONSE_TIME':
|
||||
case 'RESOURCE_LOAD_TIME':
|
||||
// 时间类指标:转换为秒或毫秒
|
||||
return value >= 1000
|
||||
? `${(value / 1000).toFixed(2)}s`
|
||||
: `${Math.round(value)}ms`;
|
||||
|
||||
case 'CLS':
|
||||
// CLS 是无单位的分数
|
||||
return value.toFixed(3);
|
||||
|
||||
case 'CACHE_HIT_RATE':
|
||||
// 百分比
|
||||
return `${value.toFixed(1)}%`;
|
||||
|
||||
default:
|
||||
// 默认保留两位小数
|
||||
return value.toFixed(2);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量计算所有 Web Vitals 指标的评级
|
||||
* @param {Object} metrics - 指标对象 { LCP: value, FCP: value, ... }
|
||||
* @returns {Object} 评级对象 { LCP: 'good', FCP: 'needs-improvement', ... }
|
||||
*/
|
||||
export const calculateAllRatings = (metrics) => {
|
||||
const ratings = {};
|
||||
|
||||
Object.entries(metrics).forEach(([metricName, value]) => {
|
||||
const thresholds = PERFORMANCE_THRESHOLDS[metricName];
|
||||
if (thresholds) {
|
||||
const isReverse = metricName === 'CACHE_HIT_RATE'; // 缓存命中率是反向评级
|
||||
ratings[metricName] = calculateRating(value, thresholds, isReverse);
|
||||
}
|
||||
});
|
||||
|
||||
return ratings;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default PERFORMANCE_THRESHOLDS;
|
||||
@@ -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 {
|
||||
|
||||
291
src/hooks/useFirstScreenMetrics.ts
Normal file
291
src/hooks/useFirstScreenMetrics.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 首屏性能指标收集 Hook
|
||||
* 整合 Web Vitals、资源加载、API 请求等指标
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const { metrics, isLoading, remeasure, exportMetrics } = useFirstScreenMetrics({
|
||||
* pageType: 'home',
|
||||
* enableConsoleLog: process.env.NODE_ENV === 'development'
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @module hooks/useFirstScreenMetrics
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { initWebVitalsTracking, getCachedMetrics } from '@utils/performance/webVitals';
|
||||
import { collectResourceStats, collectApiStats } from '@utils/performance/resourceMonitor';
|
||||
import posthog from 'posthog-js';
|
||||
import type {
|
||||
FirstScreenMetrics,
|
||||
UseFirstScreenMetricsOptions,
|
||||
UseFirstScreenMetricsResult,
|
||||
FirstScreenInteractiveEventProperties,
|
||||
} from '@/types/metrics';
|
||||
|
||||
// ============================================================
|
||||
// Hook 实现
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 首屏性能指标收集 Hook
|
||||
*/
|
||||
export const useFirstScreenMetrics = (
|
||||
options: UseFirstScreenMetricsOptions
|
||||
): UseFirstScreenMetricsResult => {
|
||||
const {
|
||||
pageType,
|
||||
enableConsoleLog = process.env.NODE_ENV === 'development',
|
||||
trackToPostHog = process.env.NODE_ENV === 'production',
|
||||
customProperties = {},
|
||||
} = options;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState<FirstScreenMetrics | null>(null);
|
||||
|
||||
// 使用 ref 记录页面加载开始时间
|
||||
const pageLoadStartRef = useRef<number>(performance.now());
|
||||
const skeletonStartRef = useRef<number>(performance.now());
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 收集所有首屏指标
|
||||
*/
|
||||
const collectAllMetrics = useCallback((): FirstScreenMetrics => {
|
||||
try {
|
||||
// 1. 初始化 Web Vitals 监控
|
||||
initWebVitalsTracking({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false, // Web Vitals 自己会上报,这里不重复
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 2. 获取 Web Vitals 指标(延迟获取,等待 LCP/FCP 等指标完成)
|
||||
const webVitalsCache = getCachedMetrics();
|
||||
const webVitals = Object.fromEntries(webVitalsCache.entries());
|
||||
|
||||
// 3. 收集资源加载统计
|
||||
const resourceStats = collectResourceStats({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false, // 避免重复上报
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 4. 收集 API 请求统计
|
||||
const apiStats = collectApiStats({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false,
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 5. 计算首屏可交互时间(TTI)
|
||||
const now = performance.now();
|
||||
const timeToInteractive = now - pageLoadStartRef.current;
|
||||
|
||||
// 6. 计算骨架屏展示时长
|
||||
const skeletonDisplayDuration = now - skeletonStartRef.current;
|
||||
|
||||
const firstScreenMetrics: FirstScreenMetrics = {
|
||||
webVitals,
|
||||
resourceStats,
|
||||
apiStats,
|
||||
timeToInteractive,
|
||||
skeletonDisplayDuration,
|
||||
measuredAt: Date.now(),
|
||||
};
|
||||
|
||||
return firstScreenMetrics;
|
||||
} catch (error) {
|
||||
console.error('Failed to collect first screen metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pageType, enableConsoleLog, trackToPostHog, customProperties]);
|
||||
|
||||
/**
|
||||
* 上报首屏可交互事件到 PostHog
|
||||
*/
|
||||
const trackFirstScreenInteractive = useCallback(
|
||||
(metrics: FirstScreenMetrics) => {
|
||||
if (!trackToPostHog || process.env.NODE_ENV !== 'production') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventProperties: FirstScreenInteractiveEventProperties = {
|
||||
tti_seconds: metrics.timeToInteractive / 1000,
|
||||
skeleton_duration_seconds: metrics.skeletonDisplayDuration / 1000,
|
||||
api_request_count: metrics.apiStats.totalRequests,
|
||||
api_avg_response_time_ms: metrics.apiStats.avgResponseTime,
|
||||
page_type: pageType,
|
||||
measured_at: metrics.measuredAt,
|
||||
...customProperties,
|
||||
};
|
||||
|
||||
posthog.capture('First Screen Interactive', eventProperties);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.log('📊 Tracked First Screen Interactive to PostHog', eventProperties);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to track first screen interactive:', error);
|
||||
}
|
||||
},
|
||||
[pageType, trackToPostHog, enableConsoleLog, customProperties]
|
||||
);
|
||||
|
||||
/**
|
||||
* 手动触发重新测量
|
||||
*/
|
||||
const remeasure = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 重置计时器
|
||||
pageLoadStartRef.current = performance.now();
|
||||
skeletonStartRef.current = performance.now();
|
||||
|
||||
// 延迟收集指标(等待 Web Vitals 完成)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const newMetrics = collectAllMetrics();
|
||||
setMetrics(newMetrics);
|
||||
trackFirstScreenInteractive(newMetrics);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.group('🎯 First Screen Metrics (Re-measured)');
|
||||
console.log('TTI:', `${(newMetrics.timeToInteractive / 1000).toFixed(2)}s`);
|
||||
console.log('Skeleton Duration:', `${(newMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`);
|
||||
console.log('API Requests:', newMetrics.apiStats.totalRequests);
|
||||
console.groupEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remeasure metrics:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 1000); // 延迟 1 秒收集
|
||||
}, [collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]);
|
||||
|
||||
/**
|
||||
* 导出指标为 JSON
|
||||
*/
|
||||
const exportMetrics = useCallback((): string => {
|
||||
if (!metrics) {
|
||||
return JSON.stringify({ error: 'No metrics available' }, null, 2);
|
||||
}
|
||||
|
||||
return JSON.stringify(metrics, null, 2);
|
||||
}, [metrics]);
|
||||
|
||||
/**
|
||||
* 初始化:在组件挂载时自动收集指标
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 防止重复初始化
|
||||
if (hasInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasInitializedRef.current = true;
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.log('🚀 useFirstScreenMetrics initialized', { pageType });
|
||||
}
|
||||
|
||||
// 延迟收集指标,等待页面渲染完成和 Web Vitals 指标就绪
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
const firstScreenMetrics = collectAllMetrics();
|
||||
setMetrics(firstScreenMetrics);
|
||||
trackFirstScreenInteractive(firstScreenMetrics);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.group('🎯 First Screen Metrics');
|
||||
console.log('━'.repeat(50));
|
||||
console.log(`✅ TTI: ${(firstScreenMetrics.timeToInteractive / 1000).toFixed(2)}s`);
|
||||
console.log(`✅ Skeleton Duration: ${(firstScreenMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`);
|
||||
console.log(`✅ API Requests: ${firstScreenMetrics.apiStats.totalRequests}`);
|
||||
console.log(`✅ API Avg Response: ${firstScreenMetrics.apiStats.avgResponseTime.toFixed(0)}ms`);
|
||||
console.log('━'.repeat(50));
|
||||
console.groupEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to collect initial metrics:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 2000); // 延迟 2 秒收集(确保 LCP/FCP 等指标已触发)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, []); // 空依赖数组,只在挂载时执行一次
|
||||
|
||||
// ============================================================
|
||||
// 返回值
|
||||
// ============================================================
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
metrics,
|
||||
remeasure,
|
||||
exportMetrics,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 辅助 Hook:标记骨架屏结束
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 标记骨架屏结束的 Hook
|
||||
* 用于在骨架屏消失时记录时间点
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const { markSkeletonEnd } = useSkeletonTiming();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (!loading) {
|
||||
* markSkeletonEnd();
|
||||
* }
|
||||
* }, [loading, markSkeletonEnd]);
|
||||
* ```
|
||||
*/
|
||||
export const useSkeletonTiming = () => {
|
||||
const skeletonStartRef = useRef<number>(performance.now());
|
||||
const skeletonEndRef = useRef<number | null>(null);
|
||||
|
||||
const markSkeletonEnd = useCallback(() => {
|
||||
if (!skeletonEndRef.current) {
|
||||
skeletonEndRef.current = performance.now();
|
||||
const duration = skeletonEndRef.current - skeletonStartRef.current;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSkeletonDuration = useCallback((): number | null => {
|
||||
if (skeletonEndRef.current) {
|
||||
return skeletonEndRef.current - skeletonStartRef.current;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
markSkeletonEnd,
|
||||
getSkeletonDuration,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default useFirstScreenMetrics;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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
233
src/mocks/handlers/agent.js
Normal 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- **PE(TTM)**: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,
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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', '关闭认证弹窗');
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
52
src/store/slices/deviceSlice.js
Normal file
52
src/store/slices/deviceSlice.js
Normal 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;
|
||||
63
src/store/slices/deviceSlice.test.js
Normal file
63
src/store/slices/deviceSlice.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
190
src/store/slices/deviceSlice.usage.example.jsx
Normal file
190
src/store/slices/deviceSlice.usage.example.jsx
Normal 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
349
src/types/metrics.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 性能指标相关的 TypeScript 类型定义
|
||||
* @module types/metrics
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Web Vitals 指标
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Web Vitals 评级
|
||||
*/
|
||||
export type MetricRating = 'good' | 'needs-improvement' | 'poor';
|
||||
|
||||
/**
|
||||
* 单个 Web Vitals 指标
|
||||
*/
|
||||
export interface WebVitalMetric {
|
||||
/** 指标名称 (如 'LCP', 'FCP') */
|
||||
name: string;
|
||||
/** 指标值(毫秒或分数) */
|
||||
value: number;
|
||||
/** 评级 (good/needs-improvement/poor) */
|
||||
rating: MetricRating;
|
||||
/** 与上周对比的差值 */
|
||||
delta?: number;
|
||||
/** 测量时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的 Web Vitals 指标集合
|
||||
*/
|
||||
export interface WebVitalsMetrics {
|
||||
/** Largest Contentful Paint - 最大内容绘制时间 */
|
||||
LCP: WebVitalMetric;
|
||||
/** First Contentful Paint - 首次内容绘制时间 */
|
||||
FCP: WebVitalMetric;
|
||||
/** Cumulative Layout Shift - 累积布局偏移 */
|
||||
CLS: WebVitalMetric;
|
||||
/** First Input Delay - 首次输入延迟 */
|
||||
FID: WebVitalMetric;
|
||||
/** Time to First Byte - 首字节时间 */
|
||||
TTFB: WebVitalMetric;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 资源加载指标
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 资源类型
|
||||
*/
|
||||
export type ResourceType = 'script' | 'stylesheet' | 'image' | 'font' | 'document' | 'other';
|
||||
|
||||
/**
|
||||
* 单个资源加载指标
|
||||
*/
|
||||
export interface ResourceTiming {
|
||||
/** 资源名称 */
|
||||
name: string;
|
||||
/** 资源类型 */
|
||||
type: ResourceType;
|
||||
/** 资源大小(字节) */
|
||||
size: number;
|
||||
/** 加载耗时(毫秒) */
|
||||
duration: number;
|
||||
/** 开始时间 */
|
||||
startTime: number;
|
||||
/** 是否来自缓存 */
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源加载统计
|
||||
*/
|
||||
export interface ResourceStats {
|
||||
/** JS 文件总大小(字节) */
|
||||
totalJsSize: number;
|
||||
/** CSS 文件总大小(字节) */
|
||||
totalCssSize: number;
|
||||
/** 图片总大小(字节) */
|
||||
totalImageSize: number;
|
||||
/** 字体总大小(字节) */
|
||||
totalFontSize: number;
|
||||
/** 总加载时间(毫秒) */
|
||||
totalLoadTime: number;
|
||||
/** 缓存命中率(0-1) */
|
||||
cacheHitRate: number;
|
||||
/** 详细的资源列表 */
|
||||
resources: ResourceTiming[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 首屏指标
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 首屏性能指标
|
||||
*/
|
||||
export interface FirstScreenMetrics {
|
||||
/** Web Vitals 指标 */
|
||||
webVitals: Partial<WebVitalsMetrics>;
|
||||
/** 资源加载统计 */
|
||||
resourceStats: ResourceStats;
|
||||
/** 首屏可交互时间(毫秒) */
|
||||
timeToInteractive: number;
|
||||
/** 骨架屏展示时长(毫秒) */
|
||||
skeletonDisplayDuration: number;
|
||||
/** API 请求统计 */
|
||||
apiStats: ApiRequestStats;
|
||||
/** 测量时间戳 */
|
||||
measuredAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 请求统计
|
||||
*/
|
||||
export interface ApiRequestStats {
|
||||
/** 总请求数 */
|
||||
totalRequests: number;
|
||||
/** 平均响应时间(毫秒) */
|
||||
avgResponseTime: number;
|
||||
/** 最慢的请求耗时(毫秒) */
|
||||
slowestRequest: number;
|
||||
/** 失败的请求数 */
|
||||
failedRequests: number;
|
||||
/** 详细的请求列表 */
|
||||
requests: ApiRequestTiming[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 API 请求时序
|
||||
*/
|
||||
export interface ApiRequestTiming {
|
||||
/** 请求 URL */
|
||||
url: string;
|
||||
/** 请求方法 */
|
||||
method: string;
|
||||
/** 响应时间(毫秒) */
|
||||
duration: number;
|
||||
/** HTTP 状态码 */
|
||||
status: number;
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 开始时间 */
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 业务指标
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 功能卡片点击统计
|
||||
*/
|
||||
export interface FeatureCardMetrics {
|
||||
/** 卡片 ID */
|
||||
cardId: string;
|
||||
/** 卡片标题 */
|
||||
cardTitle: string;
|
||||
/** 点击次数 */
|
||||
clicks: number;
|
||||
/** 点击率(CTR) */
|
||||
clickRate: number;
|
||||
/** 平均点击时间(距离页面加载的毫秒数) */
|
||||
avgClickTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页业务指标
|
||||
*/
|
||||
export interface HomePageBusinessMetrics {
|
||||
/** 页面浏览次数(PV) */
|
||||
pageViews: number;
|
||||
/** 独立访客数(UV) */
|
||||
uniqueVisitors: number;
|
||||
/** 平均停留时长(秒) */
|
||||
avgSessionDuration: number;
|
||||
/** 跳出率(0-1) */
|
||||
bounceRate: number;
|
||||
/** 登录转化率(0-1) */
|
||||
loginConversionRate: number;
|
||||
/** 功能卡片点击统计 */
|
||||
featureCards: FeatureCardMetrics[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PostHog 事件属性
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* PostHog 性能事件通用属性
|
||||
*/
|
||||
export interface PerformanceEventProperties {
|
||||
/** 页面类型 */
|
||||
page_type: string;
|
||||
/** 设备类型 */
|
||||
device_type?: string;
|
||||
/** 网络类型 */
|
||||
network_type?: string;
|
||||
/** 浏览器 */
|
||||
browser?: string;
|
||||
/** 是否已登录 */
|
||||
is_authenticated?: boolean;
|
||||
/** 测量时间戳 */
|
||||
measured_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Vitals 事件属性
|
||||
*/
|
||||
export interface WebVitalsEventProperties extends PerformanceEventProperties {
|
||||
/** 指标名称 */
|
||||
metric_name: 'LCP' | 'FCP' | 'CLS' | 'FID' | 'TTFB';
|
||||
/** 指标值 */
|
||||
metric_value: number;
|
||||
/** 评级 */
|
||||
metric_rating: MetricRating;
|
||||
/** 与上周对比 */
|
||||
delta?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源加载事件属性
|
||||
*/
|
||||
export interface ResourceLoadEventProperties extends PerformanceEventProperties {
|
||||
/** JS 总大小(KB) */
|
||||
js_size_kb: number;
|
||||
/** CSS 总大小(KB) */
|
||||
css_size_kb: number;
|
||||
/** 图片总大小(KB) */
|
||||
image_size_kb: number;
|
||||
/** 总加载时间(秒) */
|
||||
total_load_time_s: number;
|
||||
/** 缓存命中率(百分比) */
|
||||
cache_hit_rate_percent: number;
|
||||
/** 资源总数 */
|
||||
resource_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首屏可交互事件属性
|
||||
*/
|
||||
export interface FirstScreenInteractiveEventProperties extends PerformanceEventProperties {
|
||||
/** 可交互时间(秒) */
|
||||
tti_seconds: number;
|
||||
/** 骨架屏展示时长(秒) */
|
||||
skeleton_duration_seconds: number;
|
||||
/** API 请求数 */
|
||||
api_request_count: number;
|
||||
/** API 平均响应时间(毫秒) */
|
||||
api_avg_response_time_ms: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Hook 配置
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* useFirstScreenMetrics Hook 配置选项
|
||||
*/
|
||||
export interface UseFirstScreenMetricsOptions {
|
||||
/** 页面类型(用于区分不同页面) */
|
||||
pageType: string;
|
||||
/** 是否启用控制台日志 */
|
||||
enableConsoleLog?: boolean;
|
||||
/** 是否自动上报到 PostHog */
|
||||
trackToPostHog?: boolean;
|
||||
/** 自定义事件属性 */
|
||||
customProperties?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useFirstScreenMetrics Hook 返回值
|
||||
*/
|
||||
export interface UseFirstScreenMetricsResult {
|
||||
/** 是否正在测量 */
|
||||
isLoading: boolean;
|
||||
/** 首屏指标(测量完成后) */
|
||||
metrics: FirstScreenMetrics | null;
|
||||
/** 手动触发重新测量 */
|
||||
remeasure: () => void;
|
||||
/** 导出指标为 JSON */
|
||||
exportMetrics: () => string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 性能阈值配置
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 单个指标的阈值配置
|
||||
*/
|
||||
export interface MetricThreshold {
|
||||
/** good 阈值(小于此值为 good) */
|
||||
good: number;
|
||||
/** needs-improvement 阈值(小于此值为 needs-improvement,否则 poor) */
|
||||
needsImprovement: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有性能指标的阈值配置
|
||||
*/
|
||||
export interface PerformanceThresholds {
|
||||
LCP: MetricThreshold;
|
||||
FCP: MetricThreshold;
|
||||
CLS: MetricThreshold;
|
||||
FID: MetricThreshold;
|
||||
TTFB: MetricThreshold;
|
||||
TTI: MetricThreshold;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 调试面板
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 调试面板配置
|
||||
*/
|
||||
export interface DebugPanelConfig {
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
/** 默认是否展开 */
|
||||
defaultExpanded?: boolean;
|
||||
/** 刷新间隔(毫秒) */
|
||||
refreshInterval?: number;
|
||||
/** 位置 */
|
||||
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试面板数据
|
||||
*/
|
||||
export interface DebugPanelData {
|
||||
/** Web Vitals 指标 */
|
||||
webVitals: Partial<WebVitalsMetrics>;
|
||||
/** 资源统计 */
|
||||
resources: ResourceStats;
|
||||
/** API 统计 */
|
||||
api: ApiRequestStats;
|
||||
/** 首屏时间 */
|
||||
firstScreen: {
|
||||
tti: number;
|
||||
skeletonDuration: number;
|
||||
};
|
||||
/** 最后更新时间 */
|
||||
lastUpdated: number;
|
||||
}
|
||||
435
src/utils/performance/resourceMonitor.ts
Normal file
435
src/utils/performance/resourceMonitor.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* 资源加载监控工具
|
||||
* 使用 Performance API 监控 JS、CSS、图片、字体等资源的加载情况
|
||||
*
|
||||
* 功能:
|
||||
* - 监控资源加载时间
|
||||
* - 统计 bundle 大小
|
||||
* - 计算缓存命中率
|
||||
* - 监控 API 请求响应时间
|
||||
*
|
||||
* @module utils/performance/resourceMonitor
|
||||
*/
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
import type {
|
||||
ResourceTiming,
|
||||
ResourceStats,
|
||||
ResourceType,
|
||||
ApiRequestStats,
|
||||
ApiRequestTiming,
|
||||
ResourceLoadEventProperties,
|
||||
} from '@/types/metrics';
|
||||
import {
|
||||
calculateRating,
|
||||
getRatingIcon,
|
||||
getRatingConsoleColor,
|
||||
BUNDLE_SIZE_THRESHOLDS,
|
||||
RESOURCE_LOAD_TIME_THRESHOLDS,
|
||||
CACHE_HIT_RATE_THRESHOLDS,
|
||||
API_RESPONSE_TIME_THRESHOLDS,
|
||||
} from '@constants/performanceThresholds';
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
interface ResourceMonitorConfig {
|
||||
/** 是否启用控制台日志 */
|
||||
enableConsoleLog?: boolean;
|
||||
/** 是否上报到 PostHog */
|
||||
trackToPostHog?: boolean;
|
||||
/** 页面类型 */
|
||||
pageType?: string;
|
||||
/** 自定义事件属性 */
|
||||
customProperties?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 全局状态
|
||||
// ============================================================
|
||||
|
||||
let resourceStatsCache: ResourceStats | null = null;
|
||||
let apiStatsCache: ApiRequestStats | null = null;
|
||||
|
||||
// ============================================================
|
||||
// 资源类型判断
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 根据资源 URL 判断资源类型
|
||||
*/
|
||||
const getResourceType = (url: string, initiatorType: string): ResourceType => {
|
||||
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// 根据文件扩展名判断
|
||||
if (['js', 'mjs', 'jsx'].includes(extension)) return 'script';
|
||||
if (['css', 'scss', 'sass', 'less'].includes(extension)) return 'stylesheet';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico'].includes(extension)) return 'image';
|
||||
if (['woff', 'woff2', 'ttf', 'otf', 'eot'].includes(extension)) return 'font';
|
||||
if (initiatorType === 'xmlhttprequest' || initiatorType === 'fetch') return 'other'; // API 请求
|
||||
if (url.includes('/api/')) return 'other'; // API 请求
|
||||
if (extension === 'html' || initiatorType === 'navigation') return 'document';
|
||||
|
||||
return 'other';
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断资源是否来自缓存
|
||||
*/
|
||||
const isFromCache = (entry: PerformanceResourceTiming): boolean => {
|
||||
// transferSize 为 0 表示来自缓存(或 304 Not Modified)
|
||||
return entry.transferSize === 0 && entry.decodedBodySize > 0;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 资源统计
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 收集所有资源的加载信息
|
||||
*/
|
||||
export const collectResourceStats = (config: ResourceMonitorConfig = {}): ResourceStats => {
|
||||
const defaultConfig: ResourceMonitorConfig = {
|
||||
enableConsoleLog: process.env.NODE_ENV === 'development',
|
||||
trackToPostHog: process.env.NODE_ENV === 'production',
|
||||
pageType: 'unknown',
|
||||
...config,
|
||||
};
|
||||
|
||||
try {
|
||||
// 获取所有资源条目
|
||||
const resourceEntries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
||||
|
||||
const resources: ResourceTiming[] = [];
|
||||
let totalJsSize = 0;
|
||||
let totalCssSize = 0;
|
||||
let totalImageSize = 0;
|
||||
let totalFontSize = 0;
|
||||
let totalLoadTime = 0;
|
||||
let cacheHits = 0;
|
||||
|
||||
resourceEntries.forEach((entry) => {
|
||||
const type = getResourceType(entry.name, entry.initiatorType);
|
||||
const fromCache = isFromCache(entry);
|
||||
const size = entry.transferSize || entry.encodedBodySize || 0;
|
||||
const duration = entry.duration;
|
||||
|
||||
const resourceTiming: ResourceTiming = {
|
||||
name: entry.name,
|
||||
type,
|
||||
size,
|
||||
duration,
|
||||
startTime: entry.startTime,
|
||||
fromCache,
|
||||
};
|
||||
|
||||
resources.push(resourceTiming);
|
||||
|
||||
// 统计各类资源的总大小
|
||||
switch (type) {
|
||||
case 'script':
|
||||
totalJsSize += size;
|
||||
break;
|
||||
case 'stylesheet':
|
||||
totalCssSize += size;
|
||||
break;
|
||||
case 'image':
|
||||
totalImageSize += size;
|
||||
break;
|
||||
case 'font':
|
||||
totalFontSize += size;
|
||||
break;
|
||||
}
|
||||
|
||||
totalLoadTime += duration;
|
||||
|
||||
if (fromCache) {
|
||||
cacheHits++;
|
||||
}
|
||||
});
|
||||
|
||||
const cacheHitRate = resources.length > 0 ? (cacheHits / resources.length) * 100 : 0;
|
||||
|
||||
const stats: ResourceStats = {
|
||||
totalJsSize,
|
||||
totalCssSize,
|
||||
totalImageSize,
|
||||
totalFontSize,
|
||||
totalLoadTime,
|
||||
cacheHitRate,
|
||||
resources,
|
||||
};
|
||||
|
||||
// 缓存结果
|
||||
resourceStatsCache = stats;
|
||||
|
||||
// 控制台输出
|
||||
if (defaultConfig.enableConsoleLog) {
|
||||
logResourceStatsToConsole(stats);
|
||||
}
|
||||
|
||||
// 上报到 PostHog
|
||||
if (defaultConfig.trackToPostHog && process.env.NODE_ENV === 'production') {
|
||||
trackResourceStatsToPostHog(stats, defaultConfig);
|
||||
}
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('Failed to collect resource stats:', error);
|
||||
return {
|
||||
totalJsSize: 0,
|
||||
totalCssSize: 0,
|
||||
totalImageSize: 0,
|
||||
totalFontSize: 0,
|
||||
totalLoadTime: 0,
|
||||
cacheHitRate: 0,
|
||||
resources: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 控制台输出资源统计
|
||||
*/
|
||||
const logResourceStatsToConsole = (stats: ResourceStats): void => {
|
||||
console.group('📦 Resource Loading Statistics');
|
||||
console.log('━'.repeat(50));
|
||||
|
||||
// JS Bundle
|
||||
const jsRating = calculateRating(stats.totalJsSize / 1024, BUNDLE_SIZE_THRESHOLDS.js);
|
||||
const jsColor = getRatingConsoleColor(jsRating);
|
||||
const jsIcon = getRatingIcon(jsRating);
|
||||
console.log(
|
||||
`${jsIcon} ${jsColor}JS Bundle: ${(stats.totalJsSize / 1024).toFixed(2)}KB (${jsRating})\x1b[0m`
|
||||
);
|
||||
|
||||
// CSS Bundle
|
||||
const cssRating = calculateRating(stats.totalCssSize / 1024, BUNDLE_SIZE_THRESHOLDS.css);
|
||||
const cssColor = getRatingConsoleColor(cssRating);
|
||||
const cssIcon = getRatingIcon(cssRating);
|
||||
console.log(
|
||||
`${cssIcon} ${cssColor}CSS Bundle: ${(stats.totalCssSize / 1024).toFixed(2)}KB (${cssRating})\x1b[0m`
|
||||
);
|
||||
|
||||
// Images
|
||||
const imageRating = calculateRating(stats.totalImageSize / 1024, BUNDLE_SIZE_THRESHOLDS.image);
|
||||
const imageColor = getRatingConsoleColor(imageRating);
|
||||
const imageIcon = getRatingIcon(imageRating);
|
||||
console.log(
|
||||
`${imageIcon} ${imageColor}Images: ${(stats.totalImageSize / 1024).toFixed(2)}KB (${imageRating})\x1b[0m`
|
||||
);
|
||||
|
||||
// Fonts
|
||||
console.log(`📝 Fonts: ${(stats.totalFontSize / 1024).toFixed(2)}KB`);
|
||||
|
||||
// Total Load Time
|
||||
const loadTimeRating = calculateRating(stats.totalLoadTime, RESOURCE_LOAD_TIME_THRESHOLDS);
|
||||
const loadTimeColor = getRatingConsoleColor(loadTimeRating);
|
||||
const loadTimeIcon = getRatingIcon(loadTimeRating);
|
||||
console.log(
|
||||
`${loadTimeIcon} ${loadTimeColor}Total Load Time: ${(stats.totalLoadTime / 1000).toFixed(2)}s (${loadTimeRating})\x1b[0m`
|
||||
);
|
||||
|
||||
// Cache Hit Rate
|
||||
const cacheRating = calculateRating(stats.cacheHitRate, CACHE_HIT_RATE_THRESHOLDS, true);
|
||||
const cacheColor = getRatingConsoleColor(cacheRating);
|
||||
const cacheIcon = getRatingIcon(cacheRating);
|
||||
console.log(
|
||||
`${cacheIcon} ${cacheColor}Cache Hit Rate: ${stats.cacheHitRate.toFixed(1)}% (${cacheRating})\x1b[0m`
|
||||
);
|
||||
|
||||
console.log('━'.repeat(50));
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
/**
|
||||
* 上报资源统计到 PostHog
|
||||
*/
|
||||
const trackResourceStatsToPostHog = (
|
||||
stats: ResourceStats,
|
||||
config: ResourceMonitorConfig
|
||||
): void => {
|
||||
try {
|
||||
const eventProperties: ResourceLoadEventProperties = {
|
||||
js_size_kb: stats.totalJsSize / 1024,
|
||||
css_size_kb: stats.totalCssSize / 1024,
|
||||
image_size_kb: stats.totalImageSize / 1024,
|
||||
total_load_time_s: stats.totalLoadTime / 1000,
|
||||
cache_hit_rate_percent: stats.cacheHitRate,
|
||||
resource_count: stats.resources.length,
|
||||
page_type: config.pageType || 'unknown',
|
||||
measured_at: Date.now(),
|
||||
...config.customProperties,
|
||||
};
|
||||
|
||||
posthog.capture('Resource Load Complete', eventProperties);
|
||||
} catch (error) {
|
||||
console.error('Failed to track resource stats to PostHog:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// API 请求监控
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 收集 API 请求统计
|
||||
*/
|
||||
export const collectApiStats = (config: ResourceMonitorConfig = {}): ApiRequestStats => {
|
||||
try {
|
||||
const resourceEntries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
||||
|
||||
// 筛选 API 请求(通过 URL 包含 /api/ 或 initiatorType 为 fetch/xmlhttprequest)
|
||||
const apiEntries = resourceEntries.filter(
|
||||
(entry) =>
|
||||
entry.name.includes('/api/') ||
|
||||
entry.initiatorType === 'fetch' ||
|
||||
entry.initiatorType === 'xmlhttprequest'
|
||||
);
|
||||
|
||||
const requests: ApiRequestTiming[] = apiEntries.map((entry) => ({
|
||||
url: entry.name,
|
||||
method: 'GET', // Performance API 无法获取方法,默认 GET
|
||||
duration: entry.duration,
|
||||
status: 200, // Performance API 无法获取状态码,假设成功
|
||||
success: true,
|
||||
startTime: entry.startTime,
|
||||
}));
|
||||
|
||||
const totalRequests = requests.length;
|
||||
const avgResponseTime =
|
||||
totalRequests > 0
|
||||
? requests.reduce((sum, req) => sum + req.duration, 0) / totalRequests
|
||||
: 0;
|
||||
const slowestRequest =
|
||||
totalRequests > 0 ? Math.max(...requests.map((req) => req.duration)) : 0;
|
||||
const failedRequests = 0; // Performance API 无法判断失败
|
||||
|
||||
const stats: ApiRequestStats = {
|
||||
totalRequests,
|
||||
avgResponseTime,
|
||||
slowestRequest,
|
||||
failedRequests,
|
||||
requests,
|
||||
};
|
||||
|
||||
// 缓存结果
|
||||
apiStatsCache = stats;
|
||||
|
||||
// 控制台输出
|
||||
if (config.enableConsoleLog) {
|
||||
logApiStatsToConsole(stats);
|
||||
}
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('Failed to collect API stats:', error);
|
||||
return {
|
||||
totalRequests: 0,
|
||||
avgResponseTime: 0,
|
||||
slowestRequest: 0,
|
||||
failedRequests: 0,
|
||||
requests: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 控制台输出 API 统计
|
||||
*/
|
||||
const logApiStatsToConsole = (stats: ApiRequestStats): void => {
|
||||
console.group('🌐 API Request Statistics');
|
||||
console.log('━'.repeat(50));
|
||||
|
||||
console.log(`📊 Total Requests: ${stats.totalRequests}`);
|
||||
|
||||
// Average Response Time
|
||||
const avgRating = calculateRating(stats.avgResponseTime, API_RESPONSE_TIME_THRESHOLDS);
|
||||
const avgColor = getRatingConsoleColor(avgRating);
|
||||
const avgIcon = getRatingIcon(avgRating);
|
||||
console.log(
|
||||
`${avgIcon} ${avgColor}Avg Response Time: ${stats.avgResponseTime.toFixed(0)}ms (${avgRating})\x1b[0m`
|
||||
);
|
||||
|
||||
// Slowest Request
|
||||
const slowestRating = calculateRating(stats.slowestRequest, API_RESPONSE_TIME_THRESHOLDS);
|
||||
const slowestColor = getRatingConsoleColor(slowestRating);
|
||||
const slowestIcon = getRatingIcon(slowestRating);
|
||||
console.log(
|
||||
`${slowestIcon} ${slowestColor}Slowest Request: ${stats.slowestRequest.toFixed(0)}ms (${slowestRating})\x1b[0m`
|
||||
);
|
||||
|
||||
console.log(`❌ Failed Requests: ${stats.failedRequests}`);
|
||||
|
||||
console.log('━'.repeat(50));
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 导出工具函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取缓存的资源统计
|
||||
*/
|
||||
export const getCachedResourceStats = (): ResourceStats | null => {
|
||||
return resourceStatsCache;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取缓存的 API 统计
|
||||
*/
|
||||
export const getCachedApiStats = (): ApiRequestStats | null => {
|
||||
return apiStatsCache;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
export const clearStatsCache = (): void => {
|
||||
resourceStatsCache = null;
|
||||
apiStatsCache = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出资源统计为 JSON
|
||||
*/
|
||||
export const exportResourceStatsAsJSON = (): string => {
|
||||
return JSON.stringify(resourceStatsCache, null, 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出 API 统计为 JSON
|
||||
*/
|
||||
export const exportApiStatsAsJSON = (): string => {
|
||||
return JSON.stringify(apiStatsCache, null, 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 在控制台输出完整报告
|
||||
*/
|
||||
export const logPerformanceReport = (): void => {
|
||||
if (resourceStatsCache) {
|
||||
logResourceStatsToConsole(resourceStatsCache);
|
||||
}
|
||||
|
||||
if (apiStatsCache) {
|
||||
logApiStatsToConsole(apiStatsCache);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default {
|
||||
collectResourceStats,
|
||||
collectApiStats,
|
||||
getCachedResourceStats,
|
||||
getCachedApiStats,
|
||||
clearStatsCache,
|
||||
exportResourceStatsAsJSON,
|
||||
exportApiStatsAsJSON,
|
||||
logPerformanceReport,
|
||||
};
|
||||
467
src/utils/performance/webVitals.ts
Normal file
467
src/utils/performance/webVitals.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Web Vitals 性能监控工具
|
||||
* 使用 PostHog 内置的性能监控 API,无需单独安装 web-vitals 库
|
||||
*
|
||||
* 支持的指标:
|
||||
* - LCP (Largest Contentful Paint) - 最大内容绘制
|
||||
* - FCP (First Contentful Paint) - 首次内容绘制
|
||||
* - CLS (Cumulative Layout Shift) - 累积布局偏移
|
||||
* - FID (First Input Delay) - 首次输入延迟
|
||||
* - TTFB (Time to First Byte) - 首字节时间
|
||||
*
|
||||
* @module utils/performance/webVitals
|
||||
*/
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
import type { WebVitalMetric, MetricRating, WebVitalsEventProperties } from '@/types/metrics';
|
||||
import {
|
||||
calculateRating,
|
||||
getRatingIcon,
|
||||
getRatingConsoleColor,
|
||||
formatMetricValue,
|
||||
LCP_THRESHOLDS,
|
||||
FCP_THRESHOLDS,
|
||||
CLS_THRESHOLDS,
|
||||
FID_THRESHOLDS,
|
||||
TTFB_THRESHOLDS,
|
||||
} from '@constants/performanceThresholds';
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
interface WebVitalsConfig {
|
||||
/** 是否启用控制台日志 (开发环境推荐) */
|
||||
enableConsoleLog?: boolean;
|
||||
/** 是否上报到 PostHog (生产环境推荐) */
|
||||
trackToPostHog?: boolean;
|
||||
/** 页面类型 (用于区分不同页面) */
|
||||
pageType?: string;
|
||||
/** 自定义事件属性 */
|
||||
customProperties?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PerformanceMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
rating: MetricRating;
|
||||
entries: any[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 全局状态
|
||||
// ============================================================
|
||||
|
||||
let metricsCache: Map<string, WebVitalMetric> = new Map();
|
||||
let isObserving = false;
|
||||
|
||||
// ============================================================
|
||||
// 核心函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取阈值配置
|
||||
*/
|
||||
const getThresholds = (metricName: string) => {
|
||||
switch (metricName) {
|
||||
case 'LCP':
|
||||
return LCP_THRESHOLDS;
|
||||
case 'FCP':
|
||||
return FCP_THRESHOLDS;
|
||||
case 'CLS':
|
||||
return CLS_THRESHOLDS;
|
||||
case 'FID':
|
||||
return FID_THRESHOLDS;
|
||||
case 'TTFB':
|
||||
return TTFB_THRESHOLDS;
|
||||
default:
|
||||
return { good: 0, needsImprovement: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理性能指标
|
||||
*/
|
||||
const handleMetric = (
|
||||
metric: PerformanceMetric,
|
||||
config: WebVitalsConfig
|
||||
): WebVitalMetric => {
|
||||
const { name, value, rating: browserRating } = metric;
|
||||
const thresholds = getThresholds(name);
|
||||
const rating = calculateRating(value, thresholds);
|
||||
|
||||
const webVitalMetric: WebVitalMetric = {
|
||||
name,
|
||||
value,
|
||||
rating,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 缓存指标
|
||||
metricsCache.set(name, webVitalMetric);
|
||||
|
||||
// 控制台输出 (开发环境)
|
||||
if (config.enableConsoleLog) {
|
||||
logMetricToConsole(webVitalMetric);
|
||||
}
|
||||
|
||||
// 上报到 PostHog (生产环境)
|
||||
if (config.trackToPostHog && process.env.NODE_ENV === 'production') {
|
||||
trackMetricToPostHog(webVitalMetric, config);
|
||||
}
|
||||
|
||||
return webVitalMetric;
|
||||
};
|
||||
|
||||
/**
|
||||
* 控制台输出指标
|
||||
*/
|
||||
const logMetricToConsole = (metric: WebVitalMetric): void => {
|
||||
const color = getRatingConsoleColor(metric.rating);
|
||||
const icon = getRatingIcon(metric.rating);
|
||||
const formattedValue = formatMetricValue(metric.name, metric.value);
|
||||
const reset = '\x1b[0m';
|
||||
|
||||
console.log(
|
||||
`${icon} ${color}${metric.name}: ${formattedValue} (${metric.rating})${reset}`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 上报指标到 PostHog
|
||||
*/
|
||||
const trackMetricToPostHog = (
|
||||
metric: WebVitalMetric,
|
||||
config: WebVitalsConfig
|
||||
): void => {
|
||||
try {
|
||||
const eventProperties: WebVitalsEventProperties = {
|
||||
metric_name: metric.name as any,
|
||||
metric_value: metric.value,
|
||||
metric_rating: metric.rating,
|
||||
page_type: config.pageType || 'unknown',
|
||||
device_type: getDeviceType(),
|
||||
network_type: getNetworkType(),
|
||||
browser: getBrowserInfo(),
|
||||
is_authenticated: checkIfAuthenticated(),
|
||||
measured_at: metric.timestamp,
|
||||
...config.customProperties,
|
||||
};
|
||||
|
||||
posthog.capture(`Web Vitals - ${metric.name}`, eventProperties);
|
||||
} catch (error) {
|
||||
console.error('Failed to track metric to PostHog:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Performance Observer API (核心监控)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 初始化 Web Vitals 监控
|
||||
* 使用浏览器原生 Performance Observer API
|
||||
*/
|
||||
export const initWebVitalsTracking = (config: WebVitalsConfig = {}): void => {
|
||||
// 防止重复初始化
|
||||
if (isObserving) {
|
||||
console.warn('⚠️ Web Vitals tracking already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查浏览器支持
|
||||
if (typeof window === 'undefined' || !('PerformanceObserver' in window)) {
|
||||
console.warn('⚠️ PerformanceObserver not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultConfig: WebVitalsConfig = {
|
||||
enableConsoleLog: process.env.NODE_ENV === 'development',
|
||||
trackToPostHog: process.env.NODE_ENV === 'production',
|
||||
pageType: 'unknown',
|
||||
...config,
|
||||
};
|
||||
|
||||
isObserving = true;
|
||||
|
||||
if (defaultConfig.enableConsoleLog) {
|
||||
console.group('🚀 Web Vitals Performance Tracking');
|
||||
console.log('Page Type:', defaultConfig.pageType);
|
||||
console.log('Console Log:', defaultConfig.enableConsoleLog);
|
||||
console.log('PostHog Tracking:', defaultConfig.trackToPostHog);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// 监控 LCP (Largest Contentful Paint)
|
||||
observeLCP(defaultConfig);
|
||||
|
||||
// 监控 FCP (First Contentful Paint)
|
||||
observeFCP(defaultConfig);
|
||||
|
||||
// 监控 CLS (Cumulative Layout Shift)
|
||||
observeCLS(defaultConfig);
|
||||
|
||||
// 监控 FID (First Input Delay)
|
||||
observeFID(defaultConfig);
|
||||
|
||||
// 监控 TTFB (Time to First Byte)
|
||||
observeTTFB(defaultConfig);
|
||||
};
|
||||
|
||||
/**
|
||||
* 监控 LCP - Largest Contentful Paint
|
||||
*/
|
||||
const observeLCP = (config: WebVitalsConfig): void => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
|
||||
if (lastEntry) {
|
||||
handleMetric(
|
||||
{
|
||||
name: 'LCP',
|
||||
value: lastEntry.startTime,
|
||||
rating: 'good',
|
||||
entries: entries,
|
||||
},
|
||||
config
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to observe LCP:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 监控 FCP - First Contentful Paint
|
||||
*/
|
||||
const observeFCP = (config: WebVitalsConfig): void => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (entry.name === 'first-contentful-paint') {
|
||||
handleMetric(
|
||||
{
|
||||
name: 'FCP',
|
||||
value: entry.startTime,
|
||||
rating: 'good',
|
||||
entries: [entry],
|
||||
},
|
||||
config
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe({ type: 'paint', buffered: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to observe FCP:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 监控 CLS - Cumulative Layout Shift
|
||||
*/
|
||||
const observeCLS = (config: WebVitalsConfig): void => {
|
||||
try {
|
||||
let clsValue = 0;
|
||||
let clsEntries: any[] = [];
|
||||
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
|
||||
entries.forEach((entry: any) => {
|
||||
if (!entry.hadRecentInput) {
|
||||
clsValue += entry.value;
|
||||
clsEntries.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
handleMetric(
|
||||
{
|
||||
name: 'CLS',
|
||||
value: clsValue,
|
||||
rating: 'good',
|
||||
entries: clsEntries,
|
||||
},
|
||||
config
|
||||
);
|
||||
});
|
||||
|
||||
observer.observe({ type: 'layout-shift', buffered: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to observe CLS:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 监控 FID - First Input Delay
|
||||
*/
|
||||
const observeFID = (config: WebVitalsConfig): void => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
const firstInput = entries[0];
|
||||
|
||||
if (firstInput) {
|
||||
const fidValue = (firstInput as any).processingStart - firstInput.startTime;
|
||||
|
||||
handleMetric(
|
||||
{
|
||||
name: 'FID',
|
||||
value: fidValue,
|
||||
rating: 'good',
|
||||
entries: [firstInput],
|
||||
},
|
||||
config
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'first-input', buffered: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to observe FID:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 监控 TTFB - Time to First Byte
|
||||
*/
|
||||
const observeTTFB = (config: WebVitalsConfig): void => {
|
||||
try {
|
||||
const navigationEntry = performance.getEntriesByType('navigation')[0] as any;
|
||||
|
||||
if (navigationEntry) {
|
||||
const ttfb = navigationEntry.responseStart - navigationEntry.requestStart;
|
||||
|
||||
handleMetric(
|
||||
{
|
||||
name: 'TTFB',
|
||||
value: ttfb,
|
||||
rating: 'good',
|
||||
entries: [navigationEntry],
|
||||
},
|
||||
config
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to measure TTFB:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 辅助函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取设备类型
|
||||
*/
|
||||
const getDeviceType = (): string => {
|
||||
const ua = navigator.userAgent;
|
||||
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
|
||||
return 'tablet';
|
||||
}
|
||||
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
|
||||
return 'mobile';
|
||||
}
|
||||
return 'desktop';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取网络类型
|
||||
*/
|
||||
const getNetworkType = (): string => {
|
||||
const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
|
||||
return connection?.effectiveType || 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取浏览器信息
|
||||
*/
|
||||
const getBrowserInfo = (): string => {
|
||||
const ua = navigator.userAgent;
|
||||
if (ua.includes('Chrome')) return 'Chrome';
|
||||
if (ua.includes('Safari')) return 'Safari';
|
||||
if (ua.includes('Firefox')) return 'Firefox';
|
||||
if (ua.includes('Edge')) return 'Edge';
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否已登录
|
||||
*/
|
||||
const checkIfAuthenticated = (): boolean => {
|
||||
// 从 localStorage 或 cookie 中检查认证状态
|
||||
return !!localStorage.getItem('has_visited'); // 示例逻辑
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 导出工具函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取缓存的指标
|
||||
*/
|
||||
export const getCachedMetrics = (): Map<string, WebVitalMetric> => {
|
||||
return metricsCache;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个指标
|
||||
*/
|
||||
export const getCachedMetric = (metricName: string): WebVitalMetric | undefined => {
|
||||
return metricsCache.get(metricName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
export const clearMetricsCache = (): void => {
|
||||
metricsCache.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出所有指标为 JSON
|
||||
*/
|
||||
export const exportMetricsAsJSON = (): string => {
|
||||
const metrics = Array.from(metricsCache.entries()).map(([key, value]) => ({
|
||||
name: key,
|
||||
...value,
|
||||
}));
|
||||
|
||||
return JSON.stringify(metrics, null, 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 在控制台输出完整报告
|
||||
*/
|
||||
export const logPerformanceReport = (): void => {
|
||||
console.group('📊 Web Vitals Performance Report');
|
||||
console.log('━'.repeat(50));
|
||||
|
||||
metricsCache.forEach((metric) => {
|
||||
logMetricToConsole(metric);
|
||||
});
|
||||
|
||||
console.log('━'.repeat(50));
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default {
|
||||
initWebVitalsTracking,
|
||||
getCachedMetrics,
|
||||
getCachedMetric,
|
||||
clearMetricsCache,
|
||||
exportMetricsAsJSON,
|
||||
logPerformanceReport,
|
||||
};
|
||||
393
src/utils/performanceMonitor.ts
Normal file
393
src/utils/performanceMonitor.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -71,6 +71,7 @@ const LeftSidebar = ({
|
||||
>
|
||||
<Box
|
||||
w="320px"
|
||||
h="100%"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
bg="rgba(17, 24, 39, 0.8)"
|
||||
|
||||
203
src/views/AgentChat/components/RightSidebar/ToolSelector.tsx
Normal file
203
src/views/AgentChat/components/RightSidebar/ToolSelector.tsx
Normal 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;
|
||||
@@ -78,6 +78,7 @@ const RightSidebar = ({
|
||||
>
|
||||
<Box
|
||||
w="320px"
|
||||
h="100%"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
bg="rgba(17, 24, 39, 0.8)"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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} // 传递用户点击的图表类型
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
194
src/views/Home/components/HomePageSkeleton.tsx
Normal file
194
src/views/Home/components/HomePageSkeleton.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 首页骨架屏组件
|
||||
* 模拟首页的 6 个功能卡片布局,减少白屏感知时间
|
||||
*
|
||||
* 使用 Chakra UI 的 Skeleton 组件
|
||||
*
|
||||
* @module views/Home/components/HomePageSkeleton
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
VStack,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
interface HomePageSkeletonProps {
|
||||
/** 是否显示动画效果 */
|
||||
isAnimated?: boolean;
|
||||
/** 骨架屏速度(秒) */
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单个卡片骨架
|
||||
// ============================================================
|
||||
|
||||
const FeatureCardSkeleton: React.FC<{ isFeatured?: boolean }> = ({ isFeatured = false }) => {
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
p={isFeatured ? 8 : 6}
|
||||
h={isFeatured ? '350px' : '280px'}
|
||||
boxShadow={isFeatured ? 'xl' : 'md'}
|
||||
position="relative"
|
||||
>
|
||||
<VStack align="start" spacing={4} h="full">
|
||||
{/* 图标骨架 */}
|
||||
<Skeleton
|
||||
height={isFeatured ? '60px' : '48px'}
|
||||
width={isFeatured ? '60px' : '48px'}
|
||||
borderRadius="lg"
|
||||
startColor={isFeatured ? 'blue.100' : 'gray.100'}
|
||||
endColor={isFeatured ? 'blue.200' : 'gray.200'}
|
||||
/>
|
||||
|
||||
{/* 标题骨架 */}
|
||||
<Skeleton height="28px" width="70%" borderRadius="md" />
|
||||
|
||||
{/* 描述骨架 */}
|
||||
<SkeletonText
|
||||
mt="2"
|
||||
noOfLines={isFeatured ? 4 : 3}
|
||||
spacing="3"
|
||||
skeletonHeight="2"
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
{/* 按钮骨架 */}
|
||||
<Skeleton
|
||||
height="40px"
|
||||
width={isFeatured ? '140px' : '100px'}
|
||||
borderRadius="md"
|
||||
mt="auto"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* Featured 徽章骨架 */}
|
||||
{isFeatured && (
|
||||
<Skeleton
|
||||
position="absolute"
|
||||
top="4"
|
||||
right="4"
|
||||
height="24px"
|
||||
width="80px"
|
||||
borderRadius="full"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 主骨架组件
|
||||
// ============================================================
|
||||
|
||||
export const HomePageSkeleton: React.FC<HomePageSkeletonProps> = ({
|
||||
isAnimated = true,
|
||||
speed = 0.8,
|
||||
}) => {
|
||||
const containerBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
return (
|
||||
<Box
|
||||
w="full"
|
||||
minH="100vh"
|
||||
bg={containerBg}
|
||||
pt={{ base: '120px', md: '140px' }}
|
||||
pb={{ base: '60px', md: '80px' }}
|
||||
>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={{ base: 8, md: 12 }} align="stretch">
|
||||
{/* 顶部标题区域骨架 */}
|
||||
<VStack spacing={4} textAlign="center">
|
||||
{/* 主标题 */}
|
||||
<Skeleton
|
||||
height={{ base: '40px', md: '56px' }}
|
||||
width={{ base: '80%', md: '500px' }}
|
||||
borderRadius="md"
|
||||
speed={speed}
|
||||
/>
|
||||
|
||||
{/* 副标题 */}
|
||||
<Skeleton
|
||||
height={{ base: '20px', md: '24px' }}
|
||||
width={{ base: '90%', md: '600px' }}
|
||||
borderRadius="md"
|
||||
speed={speed}
|
||||
/>
|
||||
|
||||
{/* CTA 按钮 */}
|
||||
<HStack spacing={4} mt={4}>
|
||||
<Skeleton
|
||||
height="48px"
|
||||
width="140px"
|
||||
borderRadius="lg"
|
||||
speed={speed}
|
||||
/>
|
||||
<Skeleton
|
||||
height="48px"
|
||||
width="140px"
|
||||
borderRadius="lg"
|
||||
speed={speed}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 功能卡片网格骨架 */}
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, md: 2, lg: 3 }}
|
||||
spacing={{ base: 6, md: 8 }}
|
||||
mt={8}
|
||||
>
|
||||
{/* 第一张卡片 - Featured (新闻中心) */}
|
||||
<Box gridColumn={{ base: 'span 1', lg: 'span 2' }}>
|
||||
<FeatureCardSkeleton isFeatured />
|
||||
</Box>
|
||||
|
||||
{/* 其余 5 张卡片 */}
|
||||
{[1, 2, 3, 4, 5].map((index) => (
|
||||
<FeatureCardSkeleton key={index} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 底部装饰元素骨架 */}
|
||||
<HStack justify="center" spacing={8} mt={12}>
|
||||
{[1, 2, 3].map((index) => (
|
||||
<VStack key={index} spacing={2} align="center">
|
||||
<Skeleton
|
||||
height="40px"
|
||||
width="40px"
|
||||
borderRadius="full"
|
||||
speed={speed}
|
||||
/>
|
||||
<Skeleton height="16px" width="60px" borderRadius="md" speed={speed} />
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default HomePageSkeleton;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
import { getFormattedTextProps } from '../../../utils/textUtils';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import RiskDisclaimer from '../../../components/RiskDisclaimer';
|
||||
import './WordCloud.css';
|
||||
import {
|
||||
BarChart, Bar,
|
||||
PieChart, Pie, Cell,
|
||||
@@ -51,6 +50,10 @@ import {
|
||||
Treemap,
|
||||
Area, AreaChart,
|
||||
} from 'recharts';
|
||||
// 词云库 - 支持两种实现
|
||||
import { Wordcloud } from '@visx/wordcloud';
|
||||
import { scaleLog } from '@visx/scale';
|
||||
import { Text as VisxText } from '@visx/text';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import 'echarts-wordcloud';
|
||||
// 颜色配置
|
||||
@@ -59,8 +62,101 @@ const CHART_COLORS = [
|
||||
'#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB'
|
||||
];
|
||||
|
||||
// 词云图组件(使用 ECharts Wordcloud)
|
||||
const WordCloud = ({ data }) => {
|
||||
// 词云颜色常量
|
||||
const WORDCLOUD_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
|
||||
|
||||
// ==================== 词云组件实现 1: @visx/wordcloud ====================
|
||||
// 使用 SVG 渲染,React 18 原生支持,配置灵活
|
||||
const VisxWordCloud = ({ data }) => {
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 400 });
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// 监听容器尺寸变化
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
setDimensions({
|
||||
width: containerRef.current.offsetWidth,
|
||||
height: 400
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateDimensions();
|
||||
const resizeObserver = new ResizeObserver(updateDimensions);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Center h="400px">
|
||||
<VStack>
|
||||
<Text color="gray.500">暂无词云数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const words = data.slice(0, 100).map(item => ({
|
||||
name: item.name || item.text,
|
||||
value: item.value || item.count || 1
|
||||
}));
|
||||
|
||||
// 计算字体大小比例
|
||||
const fontScale = scaleLog({
|
||||
domain: [
|
||||
Math.min(...words.map(w => w.value)),
|
||||
Math.max(...words.map(w => w.value))
|
||||
],
|
||||
range: [16, 80],
|
||||
});
|
||||
|
||||
const fontSizeSetter = (datum) => fontScale(datum.value);
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} h="400px" w="100%">
|
||||
{dimensions.width > 0 && (
|
||||
<svg width={dimensions.width} height={dimensions.height}>
|
||||
<Wordcloud
|
||||
words={words}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
fontSize={fontSizeSetter}
|
||||
font="Microsoft YaHei, sans-serif"
|
||||
padding={3}
|
||||
spiral="archimedean"
|
||||
rotate={0}
|
||||
random={() => 0.5}
|
||||
>
|
||||
{(cloudWords) =>
|
||||
cloudWords.map((w, i) => (
|
||||
<VisxText
|
||||
key={w.text}
|
||||
fill={WORDCLOUD_COLORS[i % WORDCLOUD_COLORS.length]}
|
||||
textAnchor="middle"
|
||||
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
|
||||
fontSize={w.size}
|
||||
fontFamily={w.font}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{w.text}
|
||||
</VisxText>
|
||||
))
|
||||
}
|
||||
</Wordcloud>
|
||||
</svg>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 词云组件实现 2: ECharts Wordcloud ====================
|
||||
// 使用 Canvas 渲染,内置交互效果(tooltip、emphasis),配置简单
|
||||
const EChartsWordCloud = ({ data }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Center h="400px">
|
||||
@@ -97,8 +193,7 @@ const WordCloud = ({ data }) => {
|
||||
fontFamily: 'Microsoft YaHei, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: function () {
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
return WORDCLOUD_COLORS[Math.floor(Math.random() * WORDCLOUD_COLORS.length)];
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
@@ -121,6 +216,23 @@ const WordCloud = ({ data }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 词云组件包装器 ====================
|
||||
// 统一接口,支持切换两种实现方式
|
||||
const WordCloud = ({ data, engine = 'echarts' }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Center h="400px">
|
||||
<VStack>
|
||||
<Text color="gray.500">暂无词云数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// 根据 engine 参数选择实现方式
|
||||
return engine === 'visx' ? <VisxWordCloud data={data} /> : <EChartsWordCloud data={data} />;
|
||||
};
|
||||
|
||||
// 板块热力图组件
|
||||
const SectorHeatMap = ({ data }) => {
|
||||
if (!data) return null;
|
||||
|
||||
Reference in New Issue
Block a user