Compare commits
8 Commits
04248e5a99
...
before-rec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be357a1c5 | ||
|
|
9f907b3cba | ||
|
|
bb878c5346 | ||
|
|
1bc3241596 | ||
|
|
cb46971e0e | ||
| 6679d99cf9 | |||
| 2c55a53c3a | |||
| 6ad56b9882 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
123
app.py
123
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()
|
||||
|
||||
# 返回成功响应给微信
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"@visx/scale": "^3.12.0",
|
||||
"@visx/text": "^3.12.0",
|
||||
"@tsparticles/react": "^3.0.0",
|
||||
"@tsparticles/slim": "^3.0.0",
|
||||
"@visx/visx": "^3.12.0",
|
||||
"@visx/wordcloud": "^3.12.0",
|
||||
"antd": "^5.27.4",
|
||||
@@ -57,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",
|
||||
@@ -64,14 +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": "^2.13.0",
|
||||
"react-tsparticles": "^2.12.2",
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -85,12 +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,
|
||||
});
|
||||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px,更紧凑
|
||||
|
||||
// 表单数据
|
||||
|
||||
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;
|
||||
@@ -72,7 +72,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('KLineChartModal', '开始加载K线数据 (loadData)', {
|
||||
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
|
||||
stockCode: stock.stock_code,
|
||||
eventTime,
|
||||
});
|
||||
@@ -91,7 +91,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
|
||||
console.log('[KLineChartModal] 数据条数:', response.data.length);
|
||||
setData(response.data);
|
||||
logger.info('KLineChartModal', 'K线数据加载成功 (loadData)', {
|
||||
logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', {
|
||||
dataCount: response.data.length,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -111,7 +111,7 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
||||
const newType = e.target.value as ChartType;
|
||||
setChartType(newType);
|
||||
|
||||
logger.debug('StockChartKLineModal', '切换图表类型 (handleChartTypeChange)', {
|
||||
logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', {
|
||||
newType,
|
||||
});
|
||||
}, []);
|
||||
@@ -131,7 +131,7 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
||||
// 然后创建新的指标
|
||||
createSubIndicators(chart, values);
|
||||
|
||||
logger.debug('StockChartKLineModal', '切换副图指标 (handleIndicatorChange)', {
|
||||
logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', {
|
||||
indicators: values,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -76,7 +76,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('TimelineChartModal', '开始加载分时图数据 (loadData)', {
|
||||
logger.debug('TimelineChartModal', 'loadData', '开始加载分时图数据', {
|
||||
stockCode: stock.stock_code,
|
||||
eventTime,
|
||||
});
|
||||
@@ -95,7 +95,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
|
||||
console.log('[TimelineChartModal] 数据条数:', response.data.length);
|
||||
setData(response.data);
|
||||
logger.info('TimelineChartModal', '分时图数据加载成功 (loadData)', {
|
||||
logger.info('TimelineChartModal', 'loadData', '分时图数据加载成功', {
|
||||
dataCount: response.data.length,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -77,7 +77,7 @@ export const useEventMarker = (
|
||||
const createMarker = useCallback(
|
||||
(time: string, label: string, color?: string) => {
|
||||
if (!chart || !data || data.length === 0) {
|
||||
logger.warn('useEventMarker', '图表或数据未准备好 (createMarker)', {
|
||||
logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', {
|
||||
hasChart: !!chart,
|
||||
dataLength: data?.length || 0,
|
||||
});
|
||||
@@ -93,7 +93,7 @@ export const useEventMarker = (
|
||||
const overlay = createEventMarkerOverlay(eventMarker, data);
|
||||
|
||||
if (!overlay) {
|
||||
logger.warn('useEventMarker', 'Overlay 创建失败 (createMarker)', {
|
||||
logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', {
|
||||
eventMarker,
|
||||
});
|
||||
return;
|
||||
@@ -103,7 +103,7 @@ export const useEventMarker = (
|
||||
const id = chart.createOverlay(overlay);
|
||||
|
||||
if (!id || (Array.isArray(id) && id.length === 0)) {
|
||||
logger.warn('useEventMarker', '标记添加失败 (createMarker)', {
|
||||
logger.warn('useEventMarker', 'createMarker', '标记添加失败', {
|
||||
overlay,
|
||||
});
|
||||
return;
|
||||
@@ -119,12 +119,12 @@ export const useEventMarker = (
|
||||
const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult;
|
||||
setHighlightId(actualHighlightId as string);
|
||||
|
||||
logger.info('useEventMarker', '事件高亮背景创建成功 (createMarker)', {
|
||||
logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', {
|
||||
highlightId: actualHighlightId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('useEventMarker', '事件标记创建成功 (createMarker)', {
|
||||
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
|
||||
markerId: actualId,
|
||||
label,
|
||||
time,
|
||||
@@ -160,7 +160,7 @@ export const useEventMarker = (
|
||||
setMarkerId(null);
|
||||
setHighlightId(null);
|
||||
|
||||
logger.debug('useEventMarker', '移除事件标记和高亮 (removeMarker)', {
|
||||
logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', {
|
||||
markerId,
|
||||
highlightId,
|
||||
chartId: chart.id,
|
||||
@@ -187,7 +187,7 @@ export const useEventMarker = (
|
||||
setMarkerId(null);
|
||||
setHighlightId(null);
|
||||
|
||||
logger.debug('useEventMarker', '移除所有事件标记和高亮 (removeAllMarkers)', {
|
||||
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', {
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -78,12 +78,12 @@ export const useKLineChart = (
|
||||
// 图表初始化函数
|
||||
const initChart = (): boolean => {
|
||||
if (!chartRef.current) {
|
||||
logger.warn('useKLineChart', '图表容器未挂载,将在 50ms 后重试 (init)', { containerId });
|
||||
logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('useKLineChart', '开始初始化图表 (init)', {
|
||||
logger.debug('useKLineChart', 'init', '开始初始化图表', {
|
||||
containerId,
|
||||
height,
|
||||
colorMode,
|
||||
@@ -116,17 +116,17 @@ export const useKLineChart = (
|
||||
height: 100, // 固定高度 100px(约占整体的 20-25%)
|
||||
});
|
||||
|
||||
logger.debug('useKLineChart', '成交量窗格创建成功 (init)', {
|
||||
logger.debug('useKLineChart', 'init', '成交量窗格创建成功', {
|
||||
volumePaneId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('useKLineChart', '成交量窗格创建失败 (init)', {
|
||||
logger.warn('useKLineChart', 'init', '成交量窗格创建失败', {
|
||||
error: err,
|
||||
});
|
||||
// 不阻塞主流程,继续执行
|
||||
}
|
||||
|
||||
logger.info('useKLineChart', '✅ 图表初始化成功 (init)', {
|
||||
logger.info('useKLineChart', 'init', '✅ 图表初始化成功', {
|
||||
containerId,
|
||||
chartId: chartInstance.id,
|
||||
});
|
||||
@@ -146,7 +146,7 @@ export const useKLineChart = (
|
||||
// 成功,直接返回清理函数
|
||||
return () => {
|
||||
if (chartInstanceRef.current) {
|
||||
logger.debug('useKLineChart', '销毁图表实例 (dispose)', {
|
||||
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
|
||||
containerId,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
@@ -161,7 +161,7 @@ export const useKLineChart = (
|
||||
|
||||
// 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载)
|
||||
const timer = setTimeout(() => {
|
||||
logger.debug('useKLineChart', '执行延迟重试 (init)', { containerId });
|
||||
logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId });
|
||||
initChart();
|
||||
}, 50);
|
||||
|
||||
@@ -169,7 +169,7 @@ export const useKLineChart = (
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (chartInstanceRef.current) {
|
||||
logger.debug('useKLineChart', '销毁图表实例 (dispose)', {
|
||||
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
|
||||
containerId,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
@@ -196,7 +196,7 @@ export const useKLineChart = (
|
||||
: getTheme(colorMode);
|
||||
chartInstanceRef.current.setStyles(newTheme);
|
||||
|
||||
logger.debug('useKLineChart', '更新图表主题 (updateTheme)', {
|
||||
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
|
||||
colorMode,
|
||||
chartType,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
|
||||
@@ -78,7 +78,7 @@ export const useKLineData = (
|
||||
*/
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) {
|
||||
logger.warn('useKLineData', '股票代码为空 (loadData)', { chartType });
|
||||
logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export const useKLineData = (
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('useKLineData', '开始加载数据 (loadData)', {
|
||||
logger.debug('useKLineData', 'loadData', '开始加载数据', {
|
||||
stockCode,
|
||||
chartType,
|
||||
eventTime,
|
||||
@@ -126,7 +126,7 @@ export const useKLineData = (
|
||||
|
||||
setData(processedData);
|
||||
|
||||
logger.info('useKLineData', '数据加载成功 (loadData)', {
|
||||
logger.info('useKLineData', 'loadData', '数据加载成功', {
|
||||
stockCode,
|
||||
chartType,
|
||||
rawCount: rawDataList.length,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const createIndicator = (
|
||||
isStack
|
||||
);
|
||||
|
||||
logger.debug('chartUtils', '创建技术指标 (createIndicator)', {
|
||||
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
|
||||
indicatorName,
|
||||
params,
|
||||
isStack,
|
||||
@@ -70,7 +70,7 @@ export const createIndicator = (
|
||||
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
|
||||
safeChartOperation('removeIndicator', () => {
|
||||
chart.removeIndicator(indicatorId);
|
||||
logger.debug('chartUtils', '移除技术指标 (removeIndicator)', { indicatorId });
|
||||
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -94,7 +94,7 @@ export const createSubIndicators = (
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', '批量创建副图指标 (createSubIndicators)', {
|
||||
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
|
||||
indicators,
|
||||
createdIds: ids,
|
||||
});
|
||||
@@ -130,7 +130,7 @@ export const setChartZoom = (chart: Chart, zoom: number): void => {
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', '设置图表缩放 (setChartZoom)', {
|
||||
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
|
||||
zoom,
|
||||
newBarSpace,
|
||||
});
|
||||
@@ -148,7 +148,7 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
|
||||
// KLineChart 10.0: 使用 scrollToTimestamp 方法
|
||||
chart.scrollToTimestamp(timestamp);
|
||||
|
||||
logger.debug('chartUtils', '滚动到指定时间 (scrollToTimestamp)', { timestamp });
|
||||
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -160,7 +160,7 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
|
||||
export const resizeChart = (chart: Chart): void => {
|
||||
safeChartOperation('resizeChart', () => {
|
||||
chart.resize();
|
||||
logger.debug('chartUtils', '调整图表大小 (resizeChart)');
|
||||
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -194,7 +194,7 @@ export const getVisibleRange = (chart: Chart): { from: number; to: number } | nu
|
||||
export const clearChartData = (chart: Chart): void => {
|
||||
safeChartOperation('clearChartData', () => {
|
||||
chart.resetData();
|
||||
logger.debug('chartUtils', '清空图表数据 (clearChartData)');
|
||||
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -213,7 +213,7 @@ export const exportChartImage = (
|
||||
// KLineChart 10.0: 使用 getConvertPictureUrl 方法
|
||||
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
|
||||
|
||||
logger.debug('chartUtils', '导出图表图片 (exportChartImage)', {
|
||||
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
|
||||
includeOverlay,
|
||||
hasData: !!imageData,
|
||||
});
|
||||
@@ -236,7 +236,7 @@ export const toggleCrosshair = (chart: Chart, show: boolean): void => {
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', '切换十字光标 (toggleCrosshair)', { show });
|
||||
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -254,7 +254,7 @@ export const toggleGrid = (chart: Chart, show: boolean): void => {
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', '切换网格 (toggleGrid)', { show });
|
||||
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -272,7 +272,7 @@ export const subscribeChartEvent = (
|
||||
): void => {
|
||||
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
|
||||
chart.subscribeAction(eventName, handler);
|
||||
logger.debug('chartUtils', '订阅图表事件 (subscribeChartEvent)', { eventName });
|
||||
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -290,6 +290,6 @@ export const unsubscribeChartEvent = (
|
||||
): void => {
|
||||
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
|
||||
chart.unsubscribeAction(eventName, handler);
|
||||
logger.debug('chartUtils', '取消订阅图表事件 (unsubscribeChartEvent)', { eventName });
|
||||
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ export const convertToKLineData = (
|
||||
eventTime?: string
|
||||
): KLineDataPoint[] => {
|
||||
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
|
||||
logger.warn('dataAdapter', '原始数据为空 (convertToKLineData)', { chartType });
|
||||
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ const parseTimestamp = (
|
||||
}
|
||||
|
||||
// 默认返回当前时间(避免图表崩溃)
|
||||
logger.warn('dataAdapter', '无法解析时间戳,使用当前时间 (parseTimestamp)', { item });
|
||||
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
@@ -126,19 +126,19 @@ export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] =
|
||||
return data.filter((item) => {
|
||||
// 移除价格为 0 或负数的数据
|
||||
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
|
||||
logger.warn('dataAdapter', '价格异常,已移除 (validateAndCleanData)', { item });
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除 high < low 的数据(数据错误)
|
||||
if (item.high < item.low) {
|
||||
logger.warn('dataAdapter', '最高价 < 最低价,已移除 (validateAndCleanData)', { item });
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除成交量为负数的数据
|
||||
if (item.volume < 0) {
|
||||
logger.warn('dataAdapter', '成交量异常,已移除 (validateAndCleanData)', { item });
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ export const trimDataByEventTime = (
|
||||
return item.timestamp >= startTime && item.timestamp <= endTime;
|
||||
});
|
||||
|
||||
logger.debug('dataAdapter', '数据时间范围裁剪完成 (trimDataByEventTime)', {
|
||||
logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', {
|
||||
originalLength: data.length,
|
||||
trimmedLength: trimmedData.length,
|
||||
eventTime,
|
||||
@@ -260,7 +260,7 @@ export const processChartData = (
|
||||
data = trimDataByEventTime(data, eventTime, chartType);
|
||||
}
|
||||
|
||||
logger.debug('dataAdapter', '数据处理完成 (processChartData)', {
|
||||
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
|
||||
rawLength: rawData.length,
|
||||
processedLength: data.length,
|
||||
chartType,
|
||||
|
||||
@@ -27,7 +27,7 @@ export const createEventMarkerOverlay = (
|
||||
const closestPoint = findClosestDataPoint(data, marker.timestamp);
|
||||
|
||||
if (!closestPoint) {
|
||||
logger.warn('eventMarkerUtils', '未找到匹配的数据点', {
|
||||
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
|
||||
markerId: marker.id,
|
||||
timestamp: marker.timestamp,
|
||||
});
|
||||
@@ -77,7 +77,7 @@ export const createEventMarkerOverlay = (
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('eventMarkerUtils', '创建事件标记', {
|
||||
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
|
||||
markerId: marker.id,
|
||||
timestamp: closestPoint.timestamp,
|
||||
label: marker.label,
|
||||
@@ -108,7 +108,7 @@ export const createEventHighlightOverlay = (
|
||||
const closestPoint = findClosestDataPoint(data, eventTimestamp);
|
||||
|
||||
if (!closestPoint) {
|
||||
logger.warn('eventMarkerUtils', '未找到匹配的数据点');
|
||||
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const createEventHighlightOverlay = (
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('eventMarkerUtils', '创建事件高亮覆盖层', {
|
||||
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
|
||||
timestamp: closestPoint.timestamp,
|
||||
eventTime,
|
||||
});
|
||||
@@ -219,7 +219,7 @@ export const createEventMarkerOverlays = (
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('eventMarkerUtils', '批量创建事件标记', {
|
||||
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
|
||||
totalMarkers: markers.length,
|
||||
createdOverlays: overlays.length,
|
||||
});
|
||||
@@ -236,7 +236,7 @@ export const createEventMarkerOverlays = (
|
||||
export const removeEventMarker = (chart: any, markerId: string): void => {
|
||||
try {
|
||||
chart.removeOverlay(markerId);
|
||||
logger.debug('eventMarkerUtils', '移除事件标记', { markerId });
|
||||
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export const removeAllEventMarkers = (chart: any): void => {
|
||||
try {
|
||||
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
|
||||
chart.removeOverlay();
|
||||
logger.debug('eventMarkerUtils', '移除所有事件标记');
|
||||
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
|
||||
}
|
||||
@@ -276,7 +276,7 @@ export const updateEventMarker = (
|
||||
// 重新创建标记(KLineChart 10.0 不支持直接更新 overlay)
|
||||
// 注意:需要在调用方重新创建并添加 overlay
|
||||
|
||||
logger.debug('eventMarkerUtils', '更新事件标记', {
|
||||
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
|
||||
markerId,
|
||||
updates,
|
||||
});
|
||||
@@ -309,7 +309,7 @@ export const highlightEventMarker = (
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('eventMarkerUtils', '高亮事件标记', {
|
||||
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
|
||||
markerId,
|
||||
highlight,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
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>
|
||||
*/
|
||||
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,261 +0,0 @@
|
||||
# LeftSidebar 组件架构说明
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
LeftSidebar/
|
||||
├── index.tsx # 左侧栏主组件(200 行)- 组合层
|
||||
├── types.ts # TypeScript 类型定义
|
||||
├── SessionList.tsx # 会话列表组件(150 行)
|
||||
├── SessionCard.js # 会话卡片组件(保留原有)
|
||||
├── SessionSearchBar.tsx # 搜索框组件(60 行)
|
||||
└── UserInfoCard.tsx # 用户信息卡片(80 行)
|
||||
```
|
||||
|
||||
## 🎯 重构目标
|
||||
|
||||
将原来 315 行的单文件组件拆分为多个职责明确的子组件,提高代码可维护性和可测试性。
|
||||
|
||||
## 🏗️ 组件职责
|
||||
|
||||
### 1. `index.tsx` - 主组件(约 200 行)
|
||||
|
||||
**职责**:
|
||||
- 管理本地状态(搜索关键词)
|
||||
- 数据处理(搜索过滤、日期分组)
|
||||
- 布局组合(渲染标题栏、搜索框、会话列表、用户信息)
|
||||
- 处理侧边栏动画
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface LeftSidebarProps {
|
||||
isOpen: boolean; // 侧边栏是否展开
|
||||
onClose: () => void; // 关闭回调
|
||||
sessions: Session[]; // 会话列表
|
||||
currentSessionId: string | null; // 当前会话 ID
|
||||
onSessionSwitch: (id: string) => void; // 切换会话
|
||||
onNewSession: () => void; // 新建会话
|
||||
isLoadingSessions: boolean; // 加载状态
|
||||
user: UserInfo | null | undefined; // 用户信息
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `SessionList.tsx` - 会话列表(约 150 行)
|
||||
|
||||
**职责**:
|
||||
- 按日期分组渲染会话(今天、昨天、本周、更早)
|
||||
- 处理加载状态和空状态
|
||||
- 管理会话卡片的入场动画
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface SessionListProps {
|
||||
sessionGroups: SessionGroups; // 分组后的会话
|
||||
currentSessionId: string | null; // 当前会话 ID
|
||||
onSessionSwitch: (id: string) => void; // 切换会话
|
||||
isLoadingSessions: boolean; // 加载状态
|
||||
totalSessions: number; // 会话总数
|
||||
}
|
||||
```
|
||||
|
||||
**特性**:
|
||||
- "今天"分组的会话有渐进入场动画
|
||||
- 其他分组无动画(性能优化)
|
||||
- 空状态显示提示文案和图标
|
||||
|
||||
---
|
||||
|
||||
### 3. `SessionSearchBar.tsx` - 搜索框(约 60 行)
|
||||
|
||||
**职责**:
|
||||
- 提供搜索输入框
|
||||
- 显示搜索图标
|
||||
- 处理输入变化事件
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface SessionSearchBarProps {
|
||||
value: string; // 搜索关键词
|
||||
onChange: (value: string) => void; // 变化回调
|
||||
placeholder?: string; // 占位符
|
||||
}
|
||||
```
|
||||
|
||||
**设计**:
|
||||
- 毛玻璃效果背景
|
||||
- 聚焦时紫色发光边框
|
||||
- 左侧搜索图标
|
||||
|
||||
---
|
||||
|
||||
### 4. `UserInfoCard.tsx` - 用户信息卡片(约 80 行)
|
||||
|
||||
**职责**:
|
||||
- 展示用户头像和昵称
|
||||
- 展示用户订阅类型徽章
|
||||
- 处理未登录状态
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface UserInfoCardProps {
|
||||
user: UserInfo | null | undefined; // 用户信息
|
||||
}
|
||||
```
|
||||
|
||||
**设计**:
|
||||
- 头像使用渐变色背景和发光效果
|
||||
- 订阅类型使用渐变色徽章
|
||||
- 文本溢出时自动截断
|
||||
|
||||
---
|
||||
|
||||
### 5. `SessionCard.js` - 会话卡片(保留原有)
|
||||
|
||||
保留原有的 JavaScript 实现,作为原子组件被 SessionList 调用。
|
||||
|
||||
**未来可选优化**:迁移为 TypeScript。
|
||||
|
||||
---
|
||||
|
||||
### 6. `types.ts` - 类型定义
|
||||
|
||||
**导出类型**:
|
||||
```typescript
|
||||
// 会话数据结构
|
||||
interface Session {
|
||||
session_id: string;
|
||||
title?: string;
|
||||
created_at?: string;
|
||||
timestamp?: string;
|
||||
message_count?: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// 按日期分组的会话
|
||||
interface SessionGroups {
|
||||
today: Session[];
|
||||
yesterday: Session[];
|
||||
thisWeek: Session[];
|
||||
older: Session[];
|
||||
}
|
||||
|
||||
// 用户信息
|
||||
interface UserInfo {
|
||||
avatar?: string;
|
||||
nickname?: string;
|
||||
subscription_type?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 数据流
|
||||
|
||||
```
|
||||
LeftSidebar (index.tsx)
|
||||
├─ 接收 sessions 数组
|
||||
├─ 管理 searchQuery 状态
|
||||
├─ 过滤和分组数据
|
||||
│
|
||||
├─→ SessionSearchBar
|
||||
│ └─ 更新 searchQuery
|
||||
│
|
||||
├─→ SessionList
|
||||
│ ├─ 接收 sessionGroups
|
||||
│ └─→ SessionCard(循环渲染)
|
||||
│ └─ 触发 onSessionSwitch
|
||||
│
|
||||
└─→ UserInfoCard
|
||||
└─ 展示用户信息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 依赖关系
|
||||
|
||||
- **外部依赖**:
|
||||
- `@chakra-ui/react` - UI 组件库
|
||||
- `framer-motion` - 动画库
|
||||
- `lucide-react` - 图标库
|
||||
|
||||
- **内部依赖**:
|
||||
- `../../constants/animations` - 动画配置
|
||||
- `../../utils/sessionUtils` - 会话分组工具函数
|
||||
|
||||
---
|
||||
|
||||
## 🎨 设计特性
|
||||
|
||||
1. **毛玻璃效果**:
|
||||
- `backdropFilter: blur(20px) saturate(180%)`
|
||||
- 半透明背景 `rgba(17, 24, 39, 0.8)`
|
||||
|
||||
2. **渐变色**:
|
||||
- 标题:蓝色到紫色渐变
|
||||
- 订阅徽章:蓝色到紫色渐变
|
||||
- 头像背景:蓝色到紫色渐变
|
||||
|
||||
3. **交互动画**:
|
||||
- 按钮悬停:缩放 1.1x
|
||||
- 按钮点击:缩放 0.9x
|
||||
- 会话卡片悬停:缩放 1.02x + 上移 4px
|
||||
|
||||
4. **发光效果**:
|
||||
- 头像发光:`0 0 12px rgba(139, 92, 246, 0.4)`
|
||||
- 聚焦发光:`0 0 12px rgba(139, 92, 246, 0.3)`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 重构优势
|
||||
|
||||
1. **可维护性提升**:
|
||||
- 单文件从 315 行拆分为多个 60-200 行的小文件
|
||||
- 每个组件职责单一,易于理解和修改
|
||||
|
||||
2. **可测试性提升**:
|
||||
- 每个子组件可独立测试
|
||||
- 纯展示组件(SessionCard、UserInfoCard)易于编写单元测试
|
||||
|
||||
3. **可复用性提升**:
|
||||
- SessionSearchBar 可在其他地方复用
|
||||
- UserInfoCard 可在其他侧边栏复用
|
||||
|
||||
4. **类型安全**:
|
||||
- 使用 TypeScript 提供完整类型检查
|
||||
- 统一的类型定义文件(types.ts)
|
||||
|
||||
5. **性能优化**:
|
||||
- 拆分后的组件可独立优化(如 React.memo)
|
||||
- 减少不必要的重新渲染
|
||||
|
||||
---
|
||||
|
||||
## 🚀 未来优化方向
|
||||
|
||||
1. **SessionCard 迁移为 TypeScript**
|
||||
2. **添加单元测试**
|
||||
3. **使用 React.memo 优化渲染性能**
|
||||
4. **添加虚拟滚动(如会话超过 100 个)**
|
||||
5. **支持拖拽排序会话**
|
||||
6. **支持会话分组(自定义文件夹)**
|
||||
|
||||
---
|
||||
|
||||
## 📝 使用示例
|
||||
|
||||
```typescript
|
||||
import LeftSidebar from '@views/AgentChat/components/LeftSidebar';
|
||||
|
||||
<LeftSidebar
|
||||
isOpen={isLeftSidebarOpen}
|
||||
onClose={() => setIsLeftSidebarOpen(false)}
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
onSessionSwitch={switchSession}
|
||||
onNewSession={createNewSession}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
user={user}
|
||||
/>
|
||||
```
|
||||
@@ -1,126 +0,0 @@
|
||||
// src/views/AgentChat/components/LeftSidebar/SessionList.tsx
|
||||
// 会话列表组件 - 按日期分组显示会话
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Box, Text, VStack, Flex, Spinner } from '@chakra-ui/react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import SessionCard from './SessionCard';
|
||||
import type { Session, SessionGroups } from './types';
|
||||
|
||||
/**
|
||||
* SessionList 组件的 Props 类型
|
||||
*/
|
||||
interface SessionListProps {
|
||||
/** 按日期分组的会话对象 */
|
||||
sessionGroups: SessionGroups;
|
||||
/** 当前选中的会话 ID */
|
||||
currentSessionId: string | null;
|
||||
/** 切换会话回调 */
|
||||
onSessionSwitch: (sessionId: string) => void;
|
||||
/** 会话加载中状态 */
|
||||
isLoadingSessions: boolean;
|
||||
/** 会话总数(用于判断是否为空) */
|
||||
totalSessions: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionList - 会话列表组件
|
||||
*
|
||||
* 职责:
|
||||
* 1. 按日期分组显示会话(今天、昨天、本周、更早)
|
||||
* 2. 处理加载状态和空状态
|
||||
* 3. 渲染会话卡片列表
|
||||
*/
|
||||
const SessionList: React.FC<SessionListProps> = ({
|
||||
sessionGroups,
|
||||
currentSessionId,
|
||||
onSessionSwitch,
|
||||
isLoadingSessions,
|
||||
totalSessions,
|
||||
}) => {
|
||||
/**
|
||||
* 渲染会话分组
|
||||
* @param label - 分组标签(如"今天"、"昨天")
|
||||
* @param sessions - 会话数组
|
||||
* @param withAnimation - 是否应用入场动画(今天的会话有动画)
|
||||
*/
|
||||
const renderSessionGroup = (
|
||||
label: string,
|
||||
sessions: Session[],
|
||||
withAnimation: boolean = false
|
||||
): React.ReactNode => {
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
{label}
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessions.map((session, idx) => {
|
||||
const sessionCard = (
|
||||
<SessionCard
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
);
|
||||
|
||||
// 今天的会话添加渐进入场动画
|
||||
if (withAnimation) {
|
||||
return (
|
||||
<motion.div
|
||||
key={session.session_id}
|
||||
custom={idx}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
>
|
||||
{sessionCard}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他分组不添加动画
|
||||
return <div key={session.session_id}>{sessionCard}</div>;
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flex={1} p={3} overflowY="auto">
|
||||
{/* 按日期分组显示会话 */}
|
||||
{renderSessionGroup('今天', sessionGroups.today, true)}
|
||||
{renderSessionGroup('昨天', sessionGroups.yesterday)}
|
||||
{renderSessionGroup('本周', sessionGroups.thisWeek)}
|
||||
{renderSessionGroup('更早', sessionGroups.older)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoadingSessions && (
|
||||
<Flex justify="center" p={4}>
|
||||
<Spinner
|
||||
size="md"
|
||||
color="purple.500"
|
||||
emptyColor="gray.700"
|
||||
thickness="3px"
|
||||
speed="0.65s"
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{totalSessions === 0 && !isLoadingSessions && (
|
||||
<VStack textAlign="center" py={8} color="gray.500" fontSize="sm" spacing={2}>
|
||||
<MessageSquare className="w-8 h-8" style={{ opacity: 0.5, margin: '0 auto' }} />
|
||||
<Text>还没有对话历史</Text>
|
||||
<Text fontSize="xs">开始一个新对话吧!</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionList;
|
||||
@@ -1,72 +0,0 @@
|
||||
// src/views/AgentChat/components/LeftSidebar/SessionSearchBar.tsx
|
||||
// 会话搜索框组件
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Input } from '@chakra-ui/react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* SessionSearchBar 组件的 Props 类型
|
||||
*/
|
||||
interface SessionSearchBarProps {
|
||||
/** 搜索关键词 */
|
||||
value: string;
|
||||
/** 搜索关键词变化回调 */
|
||||
onChange: (value: string) => void;
|
||||
/** 占位符文本 */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionSearchBar - 会话搜索框组件
|
||||
*
|
||||
* 职责:
|
||||
* 1. 提供搜索输入框
|
||||
* 2. 显示搜索图标
|
||||
* 3. 处理输入变化事件
|
||||
*
|
||||
* 设计:
|
||||
* - 毛玻璃效果背景
|
||||
* - 聚焦时紫色发光边框
|
||||
* - 左侧搜索图标
|
||||
*/
|
||||
const SessionSearchBar: React.FC<SessionSearchBarProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '搜索对话...',
|
||||
}) => {
|
||||
return (
|
||||
<Box position="relative">
|
||||
{/* 搜索图标 */}
|
||||
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)" zIndex={1}>
|
||||
<Search className="w-4 h-4" color="#9CA3AF" />
|
||||
</Box>
|
||||
|
||||
{/* 搜索输入框 */}
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
pl={10}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.500' }}
|
||||
_hover={{
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
}}
|
||||
_focus={{
|
||||
borderColor: 'purple.400',
|
||||
boxShadow: '0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)',
|
||||
bg: 'rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionSearchBar;
|
||||
@@ -1,68 +0,0 @@
|
||||
// src/views/AgentChat/components/LeftSidebar/UserInfoCard.tsx
|
||||
// 用户信息卡片组件
|
||||
|
||||
import React from 'react';
|
||||
import { Box, HStack, Avatar, Text, Badge } from '@chakra-ui/react';
|
||||
import type { UserInfo } from './types';
|
||||
|
||||
/**
|
||||
* UserInfoCard 组件的 Props 类型
|
||||
*/
|
||||
interface UserInfoCardProps {
|
||||
/** 用户信息 */
|
||||
user: UserInfo | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserInfoCard - 用户信息卡片组件
|
||||
*
|
||||
* 职责:
|
||||
* 1. 展示用户头像和昵称
|
||||
* 2. 展示用户订阅类型徽章
|
||||
* 3. 处理未登录状态
|
||||
*
|
||||
* 设计:
|
||||
* - 头像使用渐变色背景和发光效果
|
||||
* - 订阅类型使用渐变色徽章
|
||||
* - 文本溢出时自动截断
|
||||
*/
|
||||
const UserInfoCard: React.FC<UserInfoCardProps> = ({ user }) => {
|
||||
return (
|
||||
<Box p={4} borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||
<HStack spacing={3}>
|
||||
{/* 用户头像 */}
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.nickname}
|
||||
size="sm"
|
||||
bgGradient="linear(to-br, blue.500, purple.600)"
|
||||
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
|
||||
/>
|
||||
|
||||
{/* 用户信息 */}
|
||||
<Box flex={1} minW={0}>
|
||||
{/* 用户昵称 */}
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
|
||||
{user?.nickname || '未登录'}
|
||||
</Text>
|
||||
|
||||
{/* 订阅类型徽章 */}
|
||||
<Badge
|
||||
bgGradient="linear(to-r, blue.500, purple.500)"
|
||||
color="white"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
fontWeight="semibold"
|
||||
textTransform="none"
|
||||
>
|
||||
{user?.subscription_type || 'free'}
|
||||
</Badge>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInfoCard;
|
||||
314
src/views/AgentChat/components/LeftSidebar/index.js
Normal file
314
src/views/AgentChat/components/LeftSidebar/index.js
Normal file
@@ -0,0 +1,314 @@
|
||||
// src/views/AgentChat/components/LeftSidebar/index.js
|
||||
// 左侧栏组件 - 对话历史列表
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Input,
|
||||
Avatar,
|
||||
Badge,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
HStack,
|
||||
VStack,
|
||||
Flex,
|
||||
} from '@chakra-ui/react';
|
||||
import { MessageSquare, Plus, Search, ChevronLeft } from 'lucide-react';
|
||||
import { animations } from '../../constants/animations';
|
||||
import { groupSessionsByDate } from '../../utils/sessionUtils';
|
||||
import SessionCard from './SessionCard';
|
||||
|
||||
/**
|
||||
* LeftSidebar - 左侧栏组件
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 侧边栏是否展开
|
||||
* @param {Function} props.onClose - 关闭侧边栏回调
|
||||
* @param {Array} props.sessions - 会话列表
|
||||
* @param {string|null} props.currentSessionId - 当前选中的会话 ID
|
||||
* @param {Function} props.onSessionSwitch - 切换会话回调
|
||||
* @param {Function} props.onNewSession - 新建会话回调
|
||||
* @param {boolean} props.isLoadingSessions - 会话加载中状态
|
||||
* @param {Object} props.user - 用户信息
|
||||
* @returns {JSX.Element|null}
|
||||
*/
|
||||
const LeftSidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
onSessionSwitch,
|
||||
onNewSession,
|
||||
isLoadingSessions,
|
||||
user,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 按日期分组会话
|
||||
const sessionGroups = groupSessionsByDate(sessions);
|
||||
|
||||
// 搜索过滤
|
||||
const filteredSessions = searchQuery
|
||||
? sessions.filter(
|
||||
(s) =>
|
||||
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.session_id?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: sessions;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={animations.slideInLeft}
|
||||
>
|
||||
<Box
|
||||
w="320px"
|
||||
h="100%"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
bg="rgba(17, 24, 39, 0.8)"
|
||||
backdropFilter="blur(20px) saturate(180%)"
|
||||
borderRight="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="4px 0 24px rgba(0, 0, 0, 0.3)"
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<Box p={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<HStack spacing={2}>
|
||||
<MessageSquare className="w-5 h-5" color="#60A5FA" />
|
||||
<Text
|
||||
fontWeight="semibold"
|
||||
bgGradient="linear(to-r, blue.300, purple.300)"
|
||||
bgClip="text"
|
||||
fontSize="md"
|
||||
>
|
||||
对话历史
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="新建对话">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={onNewSession}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
color="gray.300"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
_hover={{
|
||||
bg: 'rgba(59, 130, 246, 0.2)',
|
||||
borderColor: 'blue.400',
|
||||
color: 'blue.300',
|
||||
boxShadow: '0 0 12px rgba(59, 130, 246, 0.3)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
<Tooltip label="收起侧边栏">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<ChevronLeft className="w-4 h-4" />}
|
||||
onClick={onClose}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
color="gray.300"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
_hover={{
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'purple.400',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<Box position="relative">
|
||||
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)" zIndex={1}>
|
||||
<Search className="w-4 h-4" color="#9CA3AF" />
|
||||
</Box>
|
||||
<Input
|
||||
placeholder="搜索对话..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
pl={10}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.500' }}
|
||||
_hover={{
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
}}
|
||||
_focus={{
|
||||
borderColor: 'purple.400',
|
||||
boxShadow:
|
||||
'0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)',
|
||||
bg: 'rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<Box flex={1} p={3} overflowY="auto">
|
||||
{/* 按日期分组显示会话 */}
|
||||
{sessionGroups.today.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
今天
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessionGroups.today.map((session, idx) => (
|
||||
<motion.div
|
||||
key={session.session_id}
|
||||
custom={idx}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
>
|
||||
<SessionCard
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sessionGroups.yesterday.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
昨天
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessionGroups.yesterday.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sessionGroups.thisWeek.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
本周
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessionGroups.thisWeek.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sessionGroups.older.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
更早
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessionGroups.older.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoadingSessions && (
|
||||
<Flex justify="center" p={4}>
|
||||
<Spinner
|
||||
size="md"
|
||||
color="purple.500"
|
||||
emptyColor="gray.700"
|
||||
thickness="3px"
|
||||
speed="0.65s"
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{sessions.length === 0 && !isLoadingSessions && (
|
||||
<VStack textAlign="center" py={8} color="gray.500" fontSize="sm" spacing={2}>
|
||||
<MessageSquare className="w-8 h-8" style={{ opacity: 0.5, margin: '0 auto' }} />
|
||||
<Text>还没有对话历史</Text>
|
||||
<Text fontSize="xs">开始一个新对话吧!</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
<Box p={4} borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||
<HStack spacing={3}>
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.nickname}
|
||||
size="sm"
|
||||
bgGradient="linear(to-br, blue.500, purple.600)"
|
||||
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
|
||||
/>
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
|
||||
{user?.nickname || '未登录'}
|
||||
</Text>
|
||||
<Badge
|
||||
bgGradient="linear(to-r, blue.500, purple.500)"
|
||||
color="white"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
fontWeight="semibold"
|
||||
textTransform="none"
|
||||
>
|
||||
{user?.subscription_type || 'free'}
|
||||
</Badge>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftSidebar;
|
||||
@@ -1,197 +0,0 @@
|
||||
// src/views/AgentChat/components/LeftSidebar/index.tsx
|
||||
// 左侧栏组件 - 对话历史列表(重构版本)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
HStack,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { MessageSquare, Plus, ChevronLeft } from 'lucide-react';
|
||||
import { animations } from '../../constants/animations';
|
||||
import { groupSessionsByDate } from '../../utils/sessionUtils';
|
||||
import SessionSearchBar from './SessionSearchBar';
|
||||
import SessionList from './SessionList';
|
||||
import UserInfoCard from './UserInfoCard';
|
||||
import type { Session, UserInfo } from './types';
|
||||
|
||||
/**
|
||||
* LeftSidebar 组件的 Props 类型
|
||||
*/
|
||||
interface LeftSidebarProps {
|
||||
/** 侧边栏是否展开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭侧边栏回调 */
|
||||
onClose: () => void;
|
||||
/** 会话列表 */
|
||||
sessions: Session[];
|
||||
/** 当前选中的会话 ID */
|
||||
currentSessionId: string | null;
|
||||
/** 切换会话回调 */
|
||||
onSessionSwitch: (sessionId: string) => void;
|
||||
/** 新建会话回调 */
|
||||
onNewSession: () => void;
|
||||
/** 会话加载中状态 */
|
||||
isLoadingSessions: boolean;
|
||||
/** 用户信息 */
|
||||
user: UserInfo | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* LeftSidebar - 左侧栏组件(重构版本)
|
||||
*
|
||||
* 架构改进:
|
||||
* - 将会话列表逻辑提取到 SessionList 组件(150 行)
|
||||
* - 将用户信息卡片提取到 UserInfoCard 组件(80 行)
|
||||
* - 将搜索框提取到 SessionSearchBar 组件(60 行)
|
||||
* - 主组件只负责状态管理和布局组合(200 行)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 管理搜索状态
|
||||
* 2. 过滤和分组会话数据
|
||||
* 3. 组合渲染子组件
|
||||
* 4. 处理侧边栏动画
|
||||
*/
|
||||
const LeftSidebar: React.FC<LeftSidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
onSessionSwitch,
|
||||
onNewSession,
|
||||
isLoadingSessions,
|
||||
user,
|
||||
}) => {
|
||||
// ==================== 本地状态 ====================
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// ==================== 数据处理 ====================
|
||||
|
||||
// 搜索过滤
|
||||
const filteredSessions = searchQuery
|
||||
? sessions.filter(
|
||||
(s) =>
|
||||
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.session_id?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: sessions;
|
||||
|
||||
// 按日期分组会话
|
||||
const sessionGroups = groupSessionsByDate(filteredSessions);
|
||||
|
||||
// ==================== 渲染组件 ====================
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={animations.slideInLeft}
|
||||
>
|
||||
<Box
|
||||
w="320px"
|
||||
h="100%"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
bg="rgba(17, 24, 39, 0.8)"
|
||||
backdropFilter="blur(20px) saturate(180%)"
|
||||
borderRight="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="4px 0 24px rgba(0, 0, 0, 0.3)"
|
||||
>
|
||||
{/* ==================== 标题栏 ==================== */}
|
||||
<Box p={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||
{/* 标题和操作按钮 */}
|
||||
<HStack justify="space-between" mb={3}>
|
||||
{/* 左侧:标题 */}
|
||||
<HStack spacing={2}>
|
||||
<MessageSquare className="w-5 h-5" color="#60A5FA" />
|
||||
<Text
|
||||
fontWeight="semibold"
|
||||
bgGradient="linear(to-r, blue.300, purple.300)"
|
||||
bgClip="text"
|
||||
fontSize="md"
|
||||
>
|
||||
对话历史
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:新建对话 + 收起按钮 */}
|
||||
<HStack spacing={2}>
|
||||
{/* 新建对话按钮 */}
|
||||
<Tooltip label="新建对话">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={onNewSession}
|
||||
aria-label="新建对话"
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
color="gray.300"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
_hover={{
|
||||
bg: 'rgba(59, 130, 246, 0.2)',
|
||||
borderColor: 'blue.400',
|
||||
color: 'blue.300',
|
||||
boxShadow: '0 0 12px rgba(59, 130, 246, 0.3)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
|
||||
{/* 收起侧边栏按钮 */}
|
||||
<Tooltip label="收起侧边栏">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<ChevronLeft className="w-4 h-4" />}
|
||||
onClick={onClose}
|
||||
aria-label="收起侧边栏"
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
color="gray.300"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
_hover={{
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'purple.400',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 搜索框组件 */}
|
||||
<SessionSearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||
</Box>
|
||||
|
||||
{/* ==================== 会话列表组件 ==================== */}
|
||||
<SessionList
|
||||
sessionGroups={sessionGroups}
|
||||
currentSessionId={currentSessionId}
|
||||
onSessionSwitch={onSessionSwitch}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
totalSessions={sessions.length}
|
||||
/>
|
||||
|
||||
{/* ==================== 用户信息卡片组件 ==================== */}
|
||||
<UserInfoCard user={user} />
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftSidebar;
|
||||
@@ -1,33 +0,0 @@
|
||||
// src/views/AgentChat/components/LeftSidebar/types.ts
|
||||
// LeftSidebar 组件的 TypeScript 类型定义
|
||||
|
||||
/**
|
||||
* 会话数据结构
|
||||
*/
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
title?: string;
|
||||
created_at?: string;
|
||||
timestamp?: string;
|
||||
message_count?: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日期分组的会话数据
|
||||
*/
|
||||
export interface SessionGroups {
|
||||
today: Session[];
|
||||
yesterday: Session[];
|
||||
thisWeek: Session[];
|
||||
older: Session[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户信息
|
||||
*/
|
||||
export interface UserInfo {
|
||||
avatar?: string;
|
||||
nickname?: string;
|
||||
subscription_type?: string;
|
||||
}
|
||||
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;
|
||||
@@ -221,7 +221,7 @@ export const useAgentChat = ({
|
||||
loadSessions();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('useAgentChat', 'handleSendMessage', error as Error);
|
||||
logger.error('Agent chat error', error);
|
||||
|
||||
// 移除 "思考中" 和 "执行中" 消息
|
||||
setMessages((prev) =>
|
||||
|
||||
@@ -103,7 +103,7 @@ export const useAgentSessions = ({
|
||||
setSessions(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('useAgentSessions', 'loadSessions', error as Error);
|
||||
logger.error('加载会话列表失败', error);
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export const useAgentSessions = ({
|
||||
setMessages(formattedMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('useAgentSessions', 'loadSessionHistory', error as Error);
|
||||
logger.error('加载会话历史失败', error);
|
||||
}
|
||||
},
|
||||
[setMessages]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user