Compare commits

..

8 Commits

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

  2️⃣ 添加 PostHog 性能上报

  -  在 posthog.js 中新增 reportPerformanceMetrics() 函数
  -  上报所有关键性能指标(网络、渲染、React)
  -  自动计算性能评分(0-100)
  -  包含浏览器和设备信息
2025-11-25 17:04:10 +08:00
6679d99cf9 update pay function 2025-11-25 16:49:44 +08:00
2c55a53c3a update pay function 2025-11-25 16:31:46 +08:00
6ad56b9882 update pay function 2025-11-25 16:20:39 +08:00
34 changed files with 1875 additions and 1665 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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更紧凑
// 表单数据

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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}
/>
```

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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