Compare commits

..

8 Commits

Author SHA1 Message Date
zdl
d37c974d23 feat: 组组件拆分 2025-11-25 15:53:12 +08:00
zdl
11821d8256 feat: 添加 Props 类型 2025-11-25 15:46:33 +08:00
zdl
2037e65ee4 feat: 添加 Props 类型 2025-11-25 15:46:20 +08:00
zdl
fe22d48006 feat: 修复loagger报错 2025-11-25 15:42:28 +08:00
zdl
6a82a07e92 feat: 中间聊天区域组件拆分 2025-11-25 15:41:49 +08:00
zdl
04248e5a99 feat: 修复logger 报错 2025-11-25 15:30:43 +08:00
zdl
af54d8e070 feat: 修复debugger报错 2025-11-25 15:30:43 +08:00
zdl
a3cb5e928e feat: 拆分LeftSidebar 组件为ts组件 2025-11-25 15:30:43 +08:00
54 changed files with 3171 additions and 2734 deletions

View File

@@ -18,8 +18,3 @@ 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,11 +37,3 @@ 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,9 +795,6 @@ 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)
@@ -807,16 +804,11 @@ class PaymentOrder(db.Model):
expired_at = db.Column(db.DateTime, nullable=True)
remark = db.Column(db.String(200), nullable=True)
# 关联优惠码
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):
def __init__(self, user_id, plan_name, billing_cycle, amount):
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)
@@ -845,9 +837,10 @@ 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 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,
'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,
'qr_code_url': self.qr_code_url,
'status': self.status,
'is_expired': self.is_expired(),
@@ -1924,17 +1917,11 @@ 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,
original_amount=original_amount,
discount_amount=discount_amount
amount=amount
)
# 添加订阅类型标记(用于前端展示)
@@ -1944,8 +1931,12 @@ 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:
order.promo_code_id = promo_obj.id
print(f"📦 订单关联优惠码: {promo_obj.code} (ID: {promo_obj.id})")
# 注意:需要在 PaymentOrder 表中添加 promo_code_id 字段
# 如果没有该字段,这行会报错,可以注释掉
try:
order.promo_code_id = promo_obj.id
except:
pass # 如果表中没有该字段,跳过
db.session.add(order)
db.session.commit()
@@ -2067,29 +2058,6 @@ 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(),
@@ -2168,30 +2136,24 @@ def force_update_order_status(order_id):
activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle)
# 记录优惠码使用(如果使用了优惠码)
if order.promo_code_id:
if hasattr(order, 'promo_code_id') and order.promo_code_id:
try:
# 检查是否已经记录过(防止重复)
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_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)
# 更新优惠码使用次数
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" 优惠码使用记录已存在,跳过")
# 更新优惠码使用次数
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses = (promo.current_uses or 0) + 1
except Exception as e:
print(f"⚠️ 记录优惠码使用失败: {e}")
print(f"记录优惠码使用失败: {e}")
db.session.commit()
@@ -2292,37 +2254,6 @@ 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,6 +25,8 @@
"@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",
@@ -55,7 +57,6 @@
"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",
@@ -63,12 +64,14 @@
"react-responsive-masonry": "^2.7.1",
"react-router-dom": "^6.30.1",
"react-scripts": "^5.0.1",
"react-is": "^19.0.0",
"react-scroll": "^1.8.4",
"react-scroll-into-view": "^2.1.3",
"react-table": "^7.7.0",
"react-tagsinput": "3.19.0",
"react-to-print": "^3.0.3",
"react-to-print": "^2.13.0",
"react-tsparticles": "^2.12.2",
"react-to-print": "^3.0.3",
"recharts": "^3.1.2",
"sass": "^1.49.9",
"socket.io-client": "^4.7.4",

View File

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

View File

@@ -85,15 +85,12 @@ 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

@@ -1,5 +1,15 @@
import { Link } from "react-router-dom";
import { svgs } from "./svgs";
import React from "react";
interface ButtonProps {
className?: string;
href?: string;
onClick?: () => void;
children: React.ReactNode;
px?: string;
white?: boolean;
}
const Button = ({
className,
@@ -8,7 +18,7 @@ const Button = ({
children,
px,
white,
}) => {
}: ButtonProps) => {
const classes = `button relative inline-flex items-center justify-center h-11 ${
px || "px-7"
} ${white ? "text-n-8" : "text-n-1"} transition-colors hover:text-color-1 ${

View File

@@ -1,6 +1,11 @@
import { useState } from "react";
import React from "react";
const Image = ({ className, ...props }) => {
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
className?: string;
}
const Image = ({ className, ...props }: ImageProps) => {
const [loaded, setLoaded] = useState(false);
return (

View File

@@ -1,384 +0,0 @@
// 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

@@ -1,10 +1,20 @@
import React from "react";
interface SectionProps {
className?: string;
crosses?: boolean;
crossesOffset?: string;
customPaddings?: boolean;
children: React.ReactNode;
}
const Section = ({
className,
crosses,
crossesOffset,
customPaddings,
children,
}) => (
}: SectionProps) => (
<div
className={`relative ${
customPaddings ||

View File

@@ -72,7 +72,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
setError(null);
try {
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
logger.debug('KLineChartModal', '开始加载K线数据 (loadData)', {
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', 'loadData', 'K线数据加载成功', {
logger.info('KLineChartModal', 'K线数据加载成功 (loadData)', {
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,
});
},
@@ -143,7 +143,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
*/
const handleRefresh = useCallback(() => {
loadData();
logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据');
}, [loadData]);
// ==================== 计算属性 ====================

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', 'createMarker', 'Overlay 创建失败', {
logger.warn('useEventMarker', 'Overlay 创建失败 (createMarker)', {
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,
@@ -150,17 +150,17 @@ export const useEventMarker = (
try {
if (markerId) {
chart.removeOverlay(markerId);
chart.removeOverlay({ id: markerId } as any);
}
if (highlightId) {
chart.removeOverlay(highlightId);
chart.removeOverlay({ id: highlightId } as any);
}
setMarker(null);
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) {
@@ -216,10 +216,10 @@ export const useEventMarker = (
if (chart) {
try {
if (markerId) {
chart.removeOverlay(markerId);
chart.removeOverlay({ id: markerId } as any);
}
if (highlightId) {
chart.removeOverlay(highlightId);
chart.removeOverlay({ id: highlightId } as any);
}
} catch (err) {
// 忽略清理时的错误

View File

@@ -78,12 +78,12 @@ export const useKLineChart = (
// 图表初始化函数
const initChart = (): boolean => {
if (!chartRef.current) {
logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
logger.warn('useKLineChart', '图表容器未挂载,将在 50ms 后重试 (init)', { 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,
@@ -215,7 +215,6 @@ export const useKLineChart = (
const handleResize = () => {
if (chartInstanceRef.current) {
chartInstanceRef.current.resize();
logger.debug('useKLineChart', 'resize', '调整图表大小');
}
};

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

@@ -51,6 +51,8 @@ export interface RawDataPoint {
close: number;
/** 成交量 */
volume: number;
/** 成交额(可选) */
turnover?: number;
/** 均价(分时图专用) */
avg_price?: number;
/** 昨收价(用于百分比计算和基准线)- 分时图专用 */

View File

@@ -50,7 +50,7 @@ export const createIndicator = (
isStack
);
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
logger.debug('chartUtils', '创建技术指标 (createIndicator)', {
indicatorName,
params,
isStack,
@@ -69,8 +69,8 @@ export const createIndicator = (
*/
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
safeChartOperation('removeIndicator', () => {
chart.removeIndicator(indicatorId);
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
chart.removeIndicator(indicatorId ? { id: indicatorId } as any : undefined);
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 });
});
};
@@ -271,8 +271,8 @@ export const subscribeChartEvent = (
handler: (...args: any[]) => void
): void => {
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
chart.subscribeAction(eventName, handler);
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
chart.subscribeAction(eventName as any, handler);
logger.debug('chartUtils', '订阅图表事件 (subscribeChartEvent)', { eventName });
});
};
@@ -289,7 +289,7 @@ export const unsubscribeChartEvent = (
handler: (...args: any[]) => void
): void => {
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
chart.unsubscribeAction(eventName, handler);
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
chart.unsubscribeAction(eventName as any, handler);
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', 'createEventMarkerOverlay', '未找到匹配的数据点', {
logger.warn('eventMarkerUtils', '未找到匹配的数据点', {
markerId: marker.id,
timestamp: marker.timestamp,
});
@@ -64,10 +64,12 @@ export const createEventMarkerOverlay = (
style: 'fill',
color: marker.color,
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
paddingRight: EVENT_MARKER_CONFIG.text.padding,
paddingTop: EVENT_MARKER_CONFIG.text.padding,
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
padding: [
EVENT_MARKER_CONFIG.text.padding,
EVENT_MARKER_CONFIG.text.padding,
EVENT_MARKER_CONFIG.text.padding,
EVENT_MARKER_CONFIG.text.padding,
] as any,
},
},
// 标记文本内容
@@ -77,7 +79,7 @@ export const createEventMarkerOverlay = (
},
};
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
logger.debug('eventMarkerUtils', '创建事件标记', {
markerId: marker.id,
timestamp: closestPoint.timestamp,
label: marker.label,
@@ -108,7 +110,7 @@ export const createEventHighlightOverlay = (
const closestPoint = findClosestDataPoint(data, eventTimestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
logger.warn('eventMarkerUtils', '未找到匹配的数据点');
return null;
}
@@ -135,7 +137,7 @@ export const createEventHighlightOverlay = (
},
};
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
logger.debug('eventMarkerUtils', '创建事件高亮覆盖层', {
timestamp: closestPoint.timestamp,
eventTime,
});
@@ -219,7 +221,7 @@ export const createEventMarkerOverlays = (
}
});
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
logger.debug('eventMarkerUtils', '批量创建事件标记', {
totalMarkers: markers.length,
createdOverlays: overlays.length,
});
@@ -236,7 +238,7 @@ export const createEventMarkerOverlays = (
export const removeEventMarker = (chart: any, markerId: string): void => {
try {
chart.removeOverlay(markerId);
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
logger.debug('eventMarkerUtils', '移除事件标记', { markerId });
} catch (error) {
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
}
@@ -251,7 +253,7 @@ export const removeAllEventMarkers = (chart: any): void => {
try {
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
chart.removeOverlay();
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
logger.debug('eventMarkerUtils', '移除所有事件标记');
} catch (error) {
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
}
@@ -276,7 +278,7 @@ export const updateEventMarker = (
// 重新创建标记KLineChart 10.0 不支持直接更新 overlay
// 注意:需要在调用方重新创建并添加 overlay
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
logger.debug('eventMarkerUtils', '更新事件标记', {
markerId,
updates,
});
@@ -309,7 +311,7 @@ export const highlightEventMarker = (
},
});
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
logger.debug('eventMarkerUtils', '高亮事件标记', {
markerId,
highlight,
});

View File

@@ -293,105 +293,4 @@ 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,7 +6,6 @@ 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
@@ -18,7 +17,6 @@ export const store = configureStore({
stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
},
middleware: (getDefaultMiddleware) =>

View File

@@ -1,52 +0,0 @@
// 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

@@ -1,63 +0,0 @@
/**
* 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

@@ -1,190 +0,0 @@
/**
* 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

@@ -1,393 +0,0 @@
// 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

@@ -0,0 +1,204 @@
// src/views/AgentChat/components/ChatArea/ChatHeader.tsx
// 聊天区顶部标题栏组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Box,
Avatar,
Badge,
Tooltip,
IconButton,
HStack,
Flex,
Text,
} from '@chakra-ui/react';
import { Menu, RefreshCw, Settings, Cpu, Zap } from 'lucide-react';
import { AVAILABLE_MODELS } from '../../constants/models';
/**
* ChatHeader 组件的 Props 类型
*/
interface ChatHeaderProps {
/** 当前选中的模型 ID */
selectedModel: string;
/** 左侧栏是否展开 */
isLeftSidebarOpen: boolean;
/** 右侧栏是否展开 */
isRightSidebarOpen: boolean;
/** 切换左侧栏回调 */
onToggleLeftSidebar: () => void;
/** 切换右侧栏回调 */
onToggleRightSidebar: () => void;
/** 新建会话回调 */
onNewSession: () => void;
}
/**
* ChatHeader - 聊天区顶部标题栏组件
*
* 职责:
* 1. 展示 AI 头像和标题
* 2. 显示当前模型徽章
* 3. 左侧栏/右侧栏切换按钮
* 4. 新建会话按钮
*
* 设计:
* - 深色毛玻璃背景
* - 旋转的 AI 头像动画
* - 渐变色标题和徽章
*/
const ChatHeader: React.FC<ChatHeaderProps> = ({
selectedModel,
isLeftSidebarOpen,
isRightSidebarOpen,
onToggleLeftSidebar,
onToggleRightSidebar,
onNewSession,
}) => {
const currentModel = AVAILABLE_MODELS.find((m) => m.id === selectedModel);
return (
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Flex align="center" justify="space-between">
{/* 左侧:标题和徽章 */}
<HStack spacing={4}>
{/* 左侧栏切换按钮(仅在侧边栏收起时显示) */}
{!isLeftSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Menu className="w-4 h-4" />}
onClick={onToggleLeftSidebar}
aria-label="展开左侧栏"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
{/* AI 头像(旋转动画) */}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
>
<Avatar
icon={<Cpu className="w-6 h-6" />}
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 20px rgba(236, 72, 153, 0.5)"
/>
</motion.div>
{/* 标题和徽章 */}
<Box>
<Text
fontSize="xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, purple.400)"
bgClip="text"
letterSpacing="tight"
>
AI
</Text>
<HStack spacing={2} mt={1}>
{/* 智能分析徽章 */}
<Badge
bgGradient="linear(to-r, green.500, teal.500)"
color="white"
px={2}
py={1}
borderRadius="md"
display="flex"
alignItems="center"
gap={1}
boxShadow="0 2px 8px rgba(16, 185, 129, 0.3)"
>
<Zap className="w-3 h-3" />
</Badge>
{/* 模型名称徽章 */}
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
px={2}
py={1}
borderRadius="md"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{currentModel?.name || '未知模型'}
</Badge>
</HStack>
</Box>
</HStack>
{/* 右侧:操作按钮 */}
<HStack spacing={2}>
{/* 清空对话按钮 */}
<Tooltip label="清空对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<RefreshCw className="w-4 h-4" />}
onClick={onNewSession}
aria-label="清空对话"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
borderColor: 'purple.400',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{/* 右侧栏切换按钮(仅在侧边栏收起时显示) */}
{!isRightSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Settings className="w-4 h-4" />}
onClick={onToggleRightSidebar}
aria-label="展开右侧栏"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
</HStack>
</Flex>
</Box>
);
};
export default ChatHeader;

View File

@@ -0,0 +1,253 @@
// src/views/AgentChat/components/ChatArea/ChatInput.tsx
// 聊天输入框区域组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Box,
Input,
IconButton,
Tooltip,
Kbd,
HStack,
Text,
Tag,
TagLabel,
TagCloseButton,
} from '@chakra-ui/react';
import { Send, Paperclip, Image as ImageIcon } from 'lucide-react';
import type { UploadedFile } from './types';
/**
* ChatInput 组件的 Props 类型
*/
interface ChatInputProps {
/** 输入框内容 */
inputValue: string;
/** 输入框变化回调 */
onInputChange: (value: string) => void;
/** 键盘事件回调 */
onKeyPress: (e: React.KeyboardEvent<HTMLInputElement>) => void;
/** 已上传文件列表 */
uploadedFiles: UploadedFile[];
/** 文件选择回调 */
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
/** 文件删除回调 */
onFileRemove: (index: number) => void;
/** 是否正在处理中 */
isProcessing: boolean;
/** 发送消息回调 */
onSendMessage: () => void;
/** 输入框引用 */
inputRef: React.RefObject<HTMLInputElement>;
/** 文件上传输入引用 */
fileInputRef: React.RefObject<HTMLInputElement>;
}
/**
* ChatInput - 聊天输入框区域组件
*
* 职责:
* 1. 文件上传按钮Paperclip, Image
* 2. 输入框
* 3. 发送按钮
* 4. 已上传文件预览
* 5. 快捷键提示
*
* 设计:
* - 深色毛玻璃背景
* - 渐变色发送按钮
* - 悬停动画效果
* - 响应式最大宽度896px
*/
const ChatInput: React.FC<ChatInputProps> = ({
inputValue,
onInputChange,
onKeyPress,
uploadedFiles,
onFileSelect,
onFileRemove,
isProcessing,
onSendMessage,
inputRef,
fileInputRef,
}) => {
return (
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={1}
boxShadow="0 -8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Box maxW="896px" mx="auto">
{/* 已上传文件预览 */}
{uploadedFiles.length > 0 && (
<HStack mb={3} flexWrap="wrap" spacing={2}>
{uploadedFiles.map((file, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
>
<Tag
size="md"
variant="subtle"
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
borderColor="rgba(255, 255, 255, 0.1)"
borderWidth={1}
>
<TagLabel color="gray.300">{file.name}</TagLabel>
<TagCloseButton onClick={() => onFileRemove(idx)} color="gray.400" />
</Tag>
</motion.div>
))}
</HStack>
)}
{/* 输入栏 */}
<HStack spacing={2}>
{/* 隐藏的文件上传输入 */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
onChange={onFileSelect}
style={{ display: 'none' }}
/>
{/* 上传文件按钮 */}
<Tooltip label="上传文件">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<Paperclip className="w-5 h-5" />}
onClick={() => fileInputRef.current?.click()}
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',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{/* 上传图片按钮 */}
<Tooltip label="上传图片">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<ImageIcon className="w-5 h-5" />}
onClick={() => {
fileInputRef.current?.setAttribute('accept', 'image/*');
fileInputRef.current?.click();
}}
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',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{/* 输入框 */}
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyPress}
placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)"
isDisabled={isProcessing}
size="lg"
variant="outline"
borderWidth={2}
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)',
}}
/>
{/* 发送按钮 */}
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<IconButton
size="lg"
icon={!isProcessing ? <Send className="w-5 h-5" /> : undefined}
onClick={onSendMessage}
isLoading={isProcessing}
isDisabled={!inputValue.trim() || isProcessing}
aria-label="发送消息"
bgGradient="linear(to-r, blue.500, purple.600)"
color="white"
_hover={{
bgGradient: 'linear(to-r, blue.600, purple.700)',
boxShadow: '0 8px 20px rgba(139, 92, 246, 0.4)',
}}
_active={{
transform: 'translateY(0)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</HStack>
{/* 快捷键提示 */}
<HStack spacing={4} mt={2} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text></Text>
</HStack>
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Shift
</Kbd>
<Text>+</Text>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text></Text>
</HStack>
</HStack>
</Box>
</Box>
);
};
export default ChatInput;

View File

@@ -1,5 +1,5 @@
// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js
// 执行步骤显示组件
// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.tsx
// 执行步骤显示组件TypeScript 版本)
import React from 'react';
import { motion } from 'framer-motion';
@@ -18,16 +18,32 @@ import {
Text,
} from '@chakra-ui/react';
import { Activity } from 'lucide-react';
import type { ExecutionStep } from './types';
/**
* ExecutionStepsDisplay Props
*/
interface ExecutionStepsDisplayProps {
/** 执行步骤列表 */
steps: ExecutionStep[];
/** 执行计划(可选) */
plan?: unknown;
}
/**
* ExecutionStepsDisplay -
*
* @param {Object} props
* @param {Array} props.steps -
* @param {Object} props.plan -
* @returns {JSX.Element}
*
* 1.
* 2.
* 3.
*
*
* -
* - /
* -
*/
const ExecutionStepsDisplay = ({ steps, plan }) => {
const ExecutionStepsDisplay: React.FC<ExecutionStepsDisplayProps> = ({ steps, plan }) => {
return (
<Accordion allowToggle>
<AccordionItem
@@ -41,6 +57,7 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
>
{/* 手风琴标题 */}
<AccordionButton px={4} py={2}>
<HStack flex={1} spacing={2}>
<Activity className="w-4 h-4" color="#C084FC" />
@@ -58,6 +75,8 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
</HStack>
<AccordionIcon color="gray.400" />
</AccordionButton>
{/* 手风琴内容 */}
<AccordionPanel pb={4}>
<VStack spacing={2} align="stretch">
{steps.map((result, idx) => (
@@ -75,9 +94,12 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
>
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
{/* 步骤名称 */}
<Text fontSize="xs" fontWeight="medium" color="gray.300">
{idx + 1}: {result.tool_name}
</Text>
{/* 状态徽章 */}
<Badge
bgGradient={
result.status === 'success'
@@ -95,9 +117,15 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
{result.status}
</Badge>
</Flex>
<Text fontSize="xs" color="gray.500" mt={1}>
{result.execution_time?.toFixed(2)}s
</Text>
{/* 执行耗时 */}
{result.execution_time !== undefined && (
<Text fontSize="xs" color="gray.500" mt={1}>
{result.execution_time.toFixed(2)}s
</Text>
)}
{/* 错误信息 */}
{result.error && (
<Text fontSize="xs" color="red.400" mt={1}>
{result.error}

View File

@@ -0,0 +1,78 @@
// src/views/AgentChat/components/ChatArea/MessageList.tsx
// 消息列表组件
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Box, VStack } from '@chakra-ui/react';
import { animations } from '../../constants/animations';
import MessageRenderer from './MessageRenderer';
import type { Message } from './types';
/**
* MessageList 组件的 Props 类型
*/
interface MessageListProps {
/** 消息列表 */
messages: Message[];
/** 用户头像 URL */
userAvatar?: string;
/** 消息列表底部引用(用于自动滚动) */
messagesEndRef: React.RefObject<HTMLDivElement>;
}
/**
* MessageList - 消息列表组件
*
* 职责:
* 1. 渲染消息列表容器
* 2. 处理消息动画(进入/退出)
* 3. 提供自动滚动锚点
*
* 设计:
* - 渐变色背景
* - 消息淡入上滑动画
* - 退出消息淡出动画
* - 响应式最大宽度896px
*/
const MessageList: React.FC<MessageListProps> = ({
messages,
userAvatar,
messagesEndRef,
}) => {
return (
<Box
flex={1}
bgGradient="linear(to-b, rgba(17, 24, 39, 0.5), rgba(17, 24, 39, 0.3))"
overflowY="auto"
>
<motion.div
style={{ maxWidth: '896px', margin: '0 auto' }}
variants={animations.staggerContainer}
initial="initial"
animate="animate"
>
<VStack spacing={4} align="stretch">
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<motion.div
key={message.id}
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: -20 }}
layout
>
<MessageRenderer message={message} userAvatar={userAvatar} />
</motion.div>
))}
</AnimatePresence>
{/* 自动滚动锚点 */}
<div ref={messagesEndRef} />
</VStack>
</motion.div>
</Box>
);
};
export default MessageList;

View File

@@ -1,5 +1,5 @@
// src/views/AgentChat/components/ChatArea/MessageRenderer.js
// 消息渲染器组件
// src/views/AgentChat/components/ChatArea/MessageRenderer.tsx
// 消息渲染器组件TypeScript 版本)
import React from 'react';
import { motion } from 'framer-motion';
@@ -19,17 +19,31 @@ import {
import { Cpu, User, Copy, ThumbsUp, ThumbsDown, File } from 'lucide-react';
import { MessageTypes } from '../../constants/messageTypes';
import ExecutionStepsDisplay from './ExecutionStepsDisplay';
import type { Message } from './types';
/**
* MessageRenderer Props
*/
interface MessageRendererProps {
/** 消息对象 */
message: Message;
/** 用户头像 URL */
userAvatar?: string;
}
/**
* MessageRenderer -
*
* @param {Object} props
* @param {Object} props.message -
* @param {string} props.userAvatar - URL
* @returns {JSX.Element|null}
*
* 1.
* 2.
* 3. AI
* 4. AI
* 5.
*/
const MessageRenderer = ({ message, userAvatar }) => {
const MessageRenderer: React.FC<MessageRendererProps> = ({ message, userAvatar }) => {
switch (message.type) {
// 用户消息
case MessageTypes.USER:
return (
<Flex justify="flex-end">
@@ -78,6 +92,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
</Flex>
);
// AI 思考中
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
@@ -116,6 +131,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
</Flex>
);
// AI 回复
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
@@ -139,16 +155,19 @@ const MessageRenderer = ({ message, userAvatar }) => {
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody px={5} py={3}>
{/* 消息内容 */}
<Text fontSize="sm" color="gray.100" whiteSpace="pre-wrap" lineHeight="relaxed">
{message.content}
</Text>
{message.stepResults && message.stepResults.length > 0 && (
{/* 执行步骤(如果有) */}
{message.execution_results && message.execution_results.length > 0 && (
<Box mt={3}>
<ExecutionStepsDisplay steps={message.stepResults} plan={message.plan} />
<ExecutionStepsDisplay steps={message.execution_results} plan={message.plan} />
</Box>
)}
{/* 操作按钮栏 */}
<Flex
align="center"
gap={2}
@@ -157,6 +176,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
{/* 复制按钮 */}
<Tooltip label="复制">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
@@ -164,6 +184,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
variant="ghost"
icon={<Copy className="w-4 h-4" />}
onClick={() => navigator.clipboard.writeText(message.content)}
aria-label="复制消息"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
@@ -173,12 +194,15 @@ const MessageRenderer = ({ message, userAvatar }) => {
/>
</motion.div>
</Tooltip>
{/* 点赞按钮 */}
<Tooltip label="点赞">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ThumbsUp className="w-4 h-4" />}
aria-label="点赞"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
@@ -189,12 +213,15 @@ const MessageRenderer = ({ message, userAvatar }) => {
/>
</motion.div>
</Tooltip>
{/* 点踩按钮 */}
<Tooltip label="点踩">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ThumbsDown className="w-4 h-4" />}
aria-label="点踩"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
@@ -205,11 +232,14 @@ const MessageRenderer = ({ message, userAvatar }) => {
/>
</motion.div>
</Tooltip>
{/* 时间戳 */}
<Text fontSize="xs" color="gray.500" ml="auto">
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
{message.timestamp &&
new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Flex>
</CardBody>
@@ -219,6 +249,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
</Flex>
);
// 错误消息
case MessageTypes.ERROR:
return (
<Flex justify="center">

View File

@@ -0,0 +1,112 @@
// src/views/AgentChat/components/ChatArea/QuickQuestions.tsx
// 快捷问题卡片组件
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Box, Button, HStack, Text } from '@chakra-ui/react';
import { Sparkles } from 'lucide-react';
import { quickQuestions } from '../../constants/quickQuestions';
import { animations } from '../../constants/animations';
/**
* QuickQuestions 组件的 Props 类型
*/
interface QuickQuestionsProps {
/** 消息列表长度(用于判断是否显示) */
messagesCount: number;
/** 是否正在处理中 */
isProcessing: boolean;
/** 点击快捷问题回调 */
onQuestionClick: (text: string) => void;
/** 输入框引用(用于聚焦) */
inputRef: React.RefObject<HTMLInputElement>;
}
/**
* QuickQuestions - 快捷问题卡片组件
*
* 职责:
* 1. 显示快捷问题卡片网格
* 2. 仅在消息少于 2 条且未处理时显示
* 3. 点击填充输入框并聚焦
*
* 设计:
* - 2 列网格布局
* - 毛玻璃效果卡片
* - 悬停缩放和高亮动画
* - 淡入上滑进入动画
*/
const QuickQuestions: React.FC<QuickQuestionsProps> = ({
messagesCount,
isProcessing,
onQuestionClick,
inputRef,
}) => {
// 只在消息少于 2 条且未处理时显示
if (messagesCount > 2 || isProcessing) {
return null;
}
const handleQuestionClick = (text: string) => {
onQuestionClick(text);
inputRef.current?.focus();
};
return (
<AnimatePresence>
<motion.div
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<Box px={6}>
<Box maxW="896px" mx="auto">
{/* 标题 */}
<HStack fontSize="xs" color="gray.500" mb={2} fontWeight="medium" spacing={1}>
<Sparkles className="w-3 h-3" />
<Text></Text>
</HStack>
{/* 快捷问题网格 */}
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
{quickQuestions.map((question, idx) => (
<motion.div
key={idx}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<Button
variant="outline"
w="full"
justifyContent="flex-start"
h="auto"
py={3}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="gray.300"
_hover={{
bg: 'rgba(59, 130, 246, 0.15)',
borderColor: 'blue.400',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
color: 'white',
}}
onClick={() => handleQuestionClick(question.text)}
>
<Text mr={2}>{question.emoji}</Text>
<Text>{question.text}</Text>
</Button>
</motion.div>
))}
</Box>
</Box>
</Box>
</motion.div>
</AnimatePresence>
);
};
export default QuickQuestions;

View File

@@ -1,477 +0,0 @@
// src/views/AgentChat/components/ChatArea/index.js
// 中间聊天区域组件
import React, { useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Button,
Input,
Avatar,
Badge,
Tooltip,
IconButton,
Kbd,
HStack,
VStack,
Flex,
Text,
Tag,
TagLabel,
TagCloseButton,
} from '@chakra-ui/react';
import {
Send,
Menu,
RefreshCw,
Settings,
Cpu,
Zap,
Sparkles,
Paperclip,
Image as ImageIcon,
} from 'lucide-react';
import { AVAILABLE_MODELS } from '../../constants/models';
import { quickQuestions } from '../../constants/quickQuestions';
import { animations } from '../../constants/animations';
import MessageRenderer from './MessageRenderer';
/**
* ChatArea - 中间聊天区域组件
*
* @param {Object} props
* @param {Array} props.messages - 消息列表
* @param {string} props.inputValue - 输入框内容
* @param {Function} props.onInputChange - 输入框变化回调
* @param {boolean} props.isProcessing - 处理中状态
* @param {Function} props.onSendMessage - 发送消息回调
* @param {Function} props.onKeyPress - 键盘事件回调
* @param {Array} props.uploadedFiles - 已上传文件列表
* @param {Function} props.onFileSelect - 文件选择回调
* @param {Function} props.onFileRemove - 文件删除回调
* @param {string} props.selectedModel - 当前选中的模型 ID
* @param {boolean} props.isLeftSidebarOpen - 左侧栏是否展开
* @param {boolean} props.isRightSidebarOpen - 右侧栏是否展开
* @param {Function} props.onToggleLeftSidebar - 切换左侧栏回调
* @param {Function} props.onToggleRightSidebar - 切换右侧栏回调
* @param {Function} props.onNewSession - 新建会话回调
* @param {string} props.userAvatar - 用户头像 URL
* @param {RefObject} props.inputRef - 输入框引用
* @param {RefObject} props.fileInputRef - 文件上传输入引用
* @returns {JSX.Element}
*/
const ChatArea = ({
messages,
inputValue,
onInputChange,
isProcessing,
onSendMessage,
onKeyPress,
uploadedFiles,
onFileSelect,
onFileRemove,
selectedModel,
isLeftSidebarOpen,
isRightSidebarOpen,
onToggleLeftSidebar,
onToggleRightSidebar,
onNewSession,
userAvatar,
inputRef,
fileInputRef,
}) => {
// Auto-scroll 功能:当消息列表更新时,自动滚动到底部
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<Flex flex={1} direction="column">
{/* 顶部标题栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Flex align="center" justify="space-between">
<HStack spacing={4}>
{!isLeftSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Menu className="w-4 h-4" />}
onClick={onToggleLeftSidebar}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
>
<Avatar
icon={<Cpu className="w-6 h-6" />}
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 20px rgba(236, 72, 153, 0.5)"
/>
</motion.div>
<Box>
<Text
fontSize="xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, purple.400)"
bgClip="text"
letterSpacing="tight"
>
价小前投研 AI
</Text>
<HStack spacing={2} mt={1}>
<Badge
bgGradient="linear(to-r, green.500, teal.500)"
color="white"
px={2}
py={1}
borderRadius="md"
display="flex"
alignItems="center"
gap={1}
boxShadow="0 2px 8px rgba(16, 185, 129, 0.3)"
>
<Zap className="w-3 h-3" />
智能分析
</Badge>
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
px={2}
py={1}
borderRadius="md"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name}
</Badge>
</HStack>
</Box>
</HStack>
<HStack spacing={2}>
<Tooltip label="清空对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<RefreshCw className="w-4 h-4" />}
onClick={onNewSession}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
borderColor: 'purple.400',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{!isRightSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Settings className="w-4 h-4" />}
onClick={onToggleRightSidebar}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
</HStack>
</Flex>
</Box>
{/* 消息列表 */}
<Box
flex={1}
bgGradient="linear(to-b, rgba(17, 24, 39, 0.5), rgba(17, 24, 39, 0.3))"
overflowY="auto"
>
<motion.div
style={{ maxWidth: '896px', margin: '0 auto' }}
variants={animations.staggerContainer}
initial="initial"
animate="animate"
>
<VStack spacing={4} align="stretch">
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<motion.div
key={message.id}
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: -20 }}
layout
>
<MessageRenderer message={message} userAvatar={userAvatar} />
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</VStack>
</motion.div>
</Box>
{/* 快捷问题 */}
<AnimatePresence>
{messages.length <= 2 && !isProcessing && (
<motion.div
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<Box px={6}>
<Box maxW="896px" mx="auto">
<HStack fontSize="xs" color="gray.500" mb={2} fontWeight="medium" spacing={1}>
<Sparkles className="w-3 h-3" />
<Text>快速开始</Text>
</HStack>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
{quickQuestions.map((question, idx) => (
<motion.div
key={idx}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<Button
variant="outline"
w="full"
justifyContent="flex-start"
h="auto"
py={3}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="gray.300"
_hover={{
bg: 'rgba(59, 130, 246, 0.15)',
borderColor: 'blue.400',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
color: 'white',
}}
onClick={() => {
onInputChange(question.text);
inputRef.current?.focus();
}}
>
<Text mr={2}>{question.emoji}</Text>
<Text>{question.text}</Text>
</Button>
</motion.div>
))}
</Box>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
{/* 输入栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={1}
boxShadow="0 -8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Box maxW="896px" mx="auto">
{/* 已上传文件预览 */}
{uploadedFiles.length > 0 && (
<HStack mb={3} flexWrap="wrap" spacing={2}>
{uploadedFiles.map((file, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
>
<Tag
size="md"
variant="subtle"
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
borderColor="rgba(255, 255, 255, 0.1)"
borderWidth={1}
>
<TagLabel color="gray.300">{file.name}</TagLabel>
<TagCloseButton onClick={() => onFileRemove(idx)} color="gray.400" />
</Tag>
</motion.div>
))}
</HStack>
)}
<HStack spacing={2}>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
onChange={onFileSelect}
style={{ display: 'none' }}
/>
<Tooltip label="上传文件">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<Paperclip className="w-5 h-5" />}
onClick={() => fileInputRef.current?.click()}
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',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="上传图片">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<ImageIcon className="w-5 h-5" />}
onClick={() => {
fileInputRef.current?.setAttribute('accept', 'image/*');
fileInputRef.current?.click();
}}
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',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyPress}
placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)"
isDisabled={isProcessing}
size="lg"
variant="outline"
borderWidth={2}
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)',
}}
/>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<IconButton
size="lg"
icon={!isProcessing && <Send className="w-5 h-5" />}
onClick={onSendMessage}
isLoading={isProcessing}
isDisabled={!inputValue.trim() || isProcessing}
bgGradient="linear(to-r, blue.500, purple.600)"
color="white"
_hover={{
bgGradient: 'linear(to-r, blue.600, purple.700)',
boxShadow: '0 8px 20px rgba(139, 92, 246, 0.4)',
}}
_active={{
transform: 'translateY(0)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</HStack>
<HStack spacing={4} mt={2} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text>发送</Text>
</HStack>
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Shift
</Kbd>
<Text>+</Text>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text>换行</Text>
</HStack>
</HStack>
</Box>
</Box>
</Flex>
);
};
export default ChatArea;

View File

@@ -0,0 +1,141 @@
// src/views/AgentChat/components/ChatArea/index.tsx
// 中间聊天区域组件(重构版本)
import React, { useRef, useEffect } from 'react';
import { Flex } from '@chakra-ui/react';
import ChatHeader from './ChatHeader';
import MessageList from './MessageList';
import QuickQuestions from './QuickQuestions';
import ChatInput from './ChatInput';
import type { Message, UploadedFile } from './types';
/**
* ChatArea 组件的 Props 类型
*/
interface ChatAreaProps {
/** 消息列表 */
messages: Message[];
/** 输入框内容 */
inputValue: string;
/** 输入框变化回调 */
onInputChange: (value: string) => void;
/** 处理中状态 */
isProcessing: boolean;
/** 发送消息回调 */
onSendMessage: () => void;
/** 键盘事件回调 */
onKeyPress: (e: React.KeyboardEvent<HTMLInputElement>) => void;
/** 已上传文件列表 */
uploadedFiles: UploadedFile[];
/** 文件选择回调 */
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
/** 文件删除回调 */
onFileRemove: (index: number) => void;
/** 当前选中的模型 ID */
selectedModel: string;
/** 左侧栏是否展开 */
isLeftSidebarOpen: boolean;
/** 右侧栏是否展开 */
isRightSidebarOpen: boolean;
/** 切换左侧栏回调 */
onToggleLeftSidebar: () => void;
/** 切换右侧栏回调 */
onToggleRightSidebar: () => void;
/** 新建会话回调 */
onNewSession: () => void;
/** 用户头像 URL */
userAvatar?: string;
/** 输入框引用 */
inputRef: React.RefObject<HTMLInputElement>;
/** 文件上传输入引用 */
fileInputRef: React.RefObject<HTMLInputElement>;
}
/**
* ChatArea - 中间聊天区域组件(重构版本)
*
* 架构改进:
* - 顶部标题栏提取到 ChatHeader 组件100 行)
* - 消息列表提取到 MessageList 组件120 行)
* - 快捷问题提取到 QuickQuestions 组件80 行)
* - 输入框区域提取到 ChatInput 组件150 行)
* - MessageRenderer 和 ExecutionStepsDisplay 已迁移为 TS
*
* 主组件职责:
* 1. 管理消息列表自动滚动
* 2. 组合渲染所有子组件
* 3. 布局控制Flex 容器)
*/
const ChatArea: React.FC<ChatAreaProps> = ({
messages,
inputValue,
onInputChange,
isProcessing,
onSendMessage,
onKeyPress,
uploadedFiles,
onFileSelect,
onFileRemove,
selectedModel,
isLeftSidebarOpen,
isRightSidebarOpen,
onToggleLeftSidebar,
onToggleRightSidebar,
onNewSession,
userAvatar,
inputRef,
fileInputRef,
}) => {
// ==================== 自动滚动功能 ====================
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// ==================== 渲染组件 ====================
return (
<Flex flex={1} direction="column">
{/* 顶部标题栏 */}
<ChatHeader
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={onToggleLeftSidebar}
onToggleRightSidebar={onToggleRightSidebar}
onNewSession={onNewSession}
/>
{/* 消息列表 */}
<MessageList
messages={messages}
userAvatar={userAvatar}
messagesEndRef={messagesEndRef}
/>
{/* 快捷问题(仅在消息少于 2 条时显示) */}
<QuickQuestions
messagesCount={messages.length}
isProcessing={isProcessing}
onQuestionClick={onInputChange}
inputRef={inputRef}
/>
{/* 输入框区域 */}
<ChatInput
inputValue={inputValue}
onInputChange={onInputChange}
onKeyPress={onKeyPress}
uploadedFiles={uploadedFiles}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
isProcessing={isProcessing}
onSendMessage={onSendMessage}
inputRef={inputRef}
fileInputRef={fileInputRef}
/>
</Flex>
);
};
export default ChatArea;

View File

@@ -0,0 +1,44 @@
// src/views/AgentChat/components/ChatArea/types.ts
// ChatArea 组件的 TypeScript 类型定义
/**
* 上传文件结构
*/
export interface UploadedFile {
name: string;
size?: number;
type?: string;
url?: string;
}
/**
* 执行步骤结构
*/
export interface ExecutionStep {
tool_name: string;
status: 'success' | 'error' | 'pending';
execution_time?: number;
error?: string;
result?: unknown;
}
/**
* 消息数据结构
*/
export interface Message {
id: string | number;
type: string; // 使用 MessageTypes 枚举
content: string;
timestamp?: string | Date;
files?: UploadedFile[];
execution_results?: ExecutionStep[];
plan?: unknown;
}
/**
* 快捷问题结构
*/
export interface QuickQuestion {
emoji: string;
text: string;
}

View File

@@ -0,0 +1,261 @@
# 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

@@ -0,0 +1,126 @@
// 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

@@ -0,0 +1,72 @@
// 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

@@ -0,0 +1,68 @@
// 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

@@ -1,314 +0,0 @@
// 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

@@ -0,0 +1,197 @@
// 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

@@ -0,0 +1,33 @@
// 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,118 @@
// src/views/AgentChat/components/RightSidebar/ModelSelector.tsx
// 模型选择组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Card,
CardBody,
HStack,
VStack,
Box,
Text,
} from '@chakra-ui/react';
import { Check } from 'lucide-react';
import { AVAILABLE_MODELS } from '../../constants/models';
/**
* ModelSelector 组件的 Props 类型
*/
interface ModelSelectorProps {
/** 当前选中的模型 ID */
selectedModel: string;
/** 模型切换回调 */
onModelChange: (modelId: string) => void;
}
/**
* ModelSelector - 模型选择组件
*
* 职责:
* 1. 渲染模型选择卡片列表
* 2. 显示模型图标、名称、描述
* 3. 高亮当前选中模型(紫色边框 + 发光效果)
* 4. 显示选中标记Check 图标 + 弹簧动画)
*
* 设计特性:
* - 卡片渐进入场动画(延迟 `idx * 0.1`
* - 悬停缩放 1.02x + 上移 2px
* - 选中态:紫色边框 + 发光效果
* - 模型图标渐变色背景
*/
const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelChange }) => {
return (
<VStack spacing={3} align="stretch">
{AVAILABLE_MODELS.map((model, idx) => {
const isSelected = selectedModel === model.id;
return (
<motion.div
key={model.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
>
<Card
cursor="pointer"
onClick={() => onModelChange(model.id)}
bg={isSelected ? 'rgba(139, 92, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)'}
backdropFilter="blur(12px)"
borderWidth={2}
borderColor={isSelected ? 'purple.400' : 'rgba(255, 255, 255, 0.1)'}
_hover={{
borderColor: isSelected ? 'purple.400' : 'rgba(255, 255, 255, 0.2)',
boxShadow: isSelected
? '0 8px 20px rgba(139, 92, 246, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
}}
transition="all 0.3s"
>
<CardBody p={3}>
<HStack align="start" spacing={3}>
{/* 模型图标 */}
<Box
p={2}
borderRadius="lg"
bgGradient={
isSelected
? 'linear(to-br, purple.500, pink.500)'
: 'linear(to-br, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2))'
}
boxShadow={isSelected ? '0 4px 12px rgba(139, 92, 246, 0.4)' : 'none'}
>
{model.icon}
</Box>
{/* 模型信息 */}
<Box flex={1}>
<Text fontWeight="semibold" fontSize="sm" color="gray.100">
{model.name}
</Text>
<Text fontSize="xs" color="gray.400" mt={1}>
{model.description}
</Text>
</Box>
{/* 选中标记Check 图标) */}
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
>
<Check className="w-5 h-5" color="#A78BFA" />
</motion.div>
)}
</HStack>
</CardBody>
</Card>
</motion.div>
);
})}
</VStack>
);
};
export default ModelSelector;

View File

@@ -0,0 +1,119 @@
// src/views/AgentChat/components/RightSidebar/Statistics.tsx
// 统计信息组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Card,
CardBody,
VStack,
Flex,
Box,
Text,
} from '@chakra-ui/react';
import { MessageSquare, Activity, Code } from 'lucide-react';
/**
* Statistics 组件的 Props 类型
*/
interface StatisticsProps {
/** 对话总数 */
sessionsCount: number;
/** 消息总数 */
messagesCount: number;
/** 已选工具数 */
selectedToolsCount: number;
}
/**
* Statistics - 统计信息组件
*
* 职责:
* 1. 显示统计卡片(对话数、消息数、已选工具数)
* 2. 渐变色大数字展示
* 3. 图标装饰
*
* 设计特性:
* - 卡片渐进入场动画(延迟 `idx * 0.1`
* - 毛玻璃效果卡片
* - 渐变色数字(蓝紫/紫粉/绿青)
* - 半透明图标装饰
*/
const Statistics: React.FC<StatisticsProps> = ({
sessionsCount,
messagesCount,
selectedToolsCount,
}) => {
const stats = [
{
label: '对话数',
value: sessionsCount,
gradient: 'linear(to-r, blue.400, purple.400)',
icon: MessageSquare,
iconColor: '#60A5FA',
},
{
label: '消息数',
value: messagesCount,
gradient: 'linear(to-r, purple.400, pink.400)',
icon: Activity,
iconColor: '#C084FC',
},
{
label: '已选工具',
value: selectedToolsCount,
gradient: 'linear(to-r, green.400, teal.400)',
icon: Code,
iconColor: '#34D399',
},
];
return (
<VStack spacing={4} align="stretch">
{stats.map((stat, idx) => {
const Icon = stat.icon;
return (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody p={4}>
<Flex align="center" justify="space-between">
{/* 左侧:标签和数值 */}
<Box>
<Text fontSize="xs" color="gray.400">
{stat.label}
</Text>
<Text
fontSize="2xl"
fontWeight="bold"
bgGradient={stat.gradient}
bgClip="text"
>
{stat.value}
</Text>
</Box>
{/* 右侧:图标装饰 */}
<Icon className="w-8 h-8" color={stat.iconColor} style={{ opacity: 0.5 }} />
</Flex>
</CardBody>
</Card>
</motion.div>
);
})}
</VStack>
);
};
export default Statistics;

View File

@@ -155,46 +155,42 @@ const ToolSelector: React.FC<ToolSelectorProps> = ({ selectedTools, onToolsChang
{/* 全选/清空按钮 */}
<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>
<motion.div flex={1} 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 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>
<motion.div flex={1} 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>
</HStack>
</>
);

View File

@@ -1,499 +0,0 @@
// src/views/AgentChat/components/RightSidebar/index.js
// 右侧栏组件 - 配置中心
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Button,
Badge,
Checkbox,
CheckboxGroup,
Tooltip,
IconButton,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Card,
CardBody,
HStack,
VStack,
Flex,
Text,
} from '@chakra-ui/react';
import {
Settings,
ChevronRight,
Cpu,
Code,
BarChart3,
Check,
MessageSquare,
Activity,
} from 'lucide-react';
import { animations } from '../../constants/animations';
import { AVAILABLE_MODELS } from '../../constants/models';
import { MCP_TOOLS, TOOL_CATEGORIES } from '../../constants/tools';
/**
* RightSidebar - 右侧栏组件(配置中心)
*
* @param {Object} props
* @param {boolean} props.isOpen - 侧边栏是否展开
* @param {Function} props.onClose - 关闭侧边栏回调
* @param {string} props.selectedModel - 当前选中的模型 ID
* @param {Function} props.onModelChange - 模型切换回调
* @param {Array} props.selectedTools - 已选工具 ID 列表
* @param {Function} props.onToolsChange - 工具选择变化回调
* @param {number} props.sessionsCount - 会话总数
* @param {number} props.messagesCount - 消息总数
* @returns {JSX.Element|null}
*/
const RightSidebar = ({
isOpen,
onClose,
selectedModel,
onModelChange,
selectedTools,
onToolsChange,
sessionsCount,
messagesCount,
}) => {
return (
<AnimatePresence>
{isOpen && (
<motion.div
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInRight}
>
<Box
w="320px"
h="100%"
display="flex"
flexDirection="column"
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderLeft="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">
<HStack spacing={2}>
<Settings className="w-5 h-5" color="#C084FC" />
<Text
fontWeight="semibold"
bgGradient="linear(to-r, purple.300, pink.300)"
bgClip="text"
fontSize="md"
>
配置中心
</Text>
</HStack>
<Tooltip label="收起侧边栏">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ChevronRight 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>
</Box>
{/* Tab 面板 */}
<Box flex={1} overflowY="auto">
<Tabs colorScheme="purple" variant="line">
<TabList px={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<Tab
color="gray.400"
_selected={{
color: 'purple.400',
borderColor: 'purple.500',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)',
}}
>
<HStack spacing={2}>
<Cpu className="w-4 h-4" />
<Text>模型</Text>
</HStack>
</Tab>
<Tab
color="gray.400"
_selected={{
color: 'purple.400',
borderColor: 'purple.500',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)',
}}
>
<HStack spacing={2}>
<Code className="w-4 h-4" />
<Text>工具</Text>
{selectedTools.length > 0 && (
<Badge
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
borderRadius="full"
fontSize="xs"
px={2}
py={0.5}
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{selectedTools.length}
</Badge>
)}
</HStack>
</Tab>
<Tab
color="gray.400"
_selected={{
color: 'purple.400',
borderColor: 'purple.500',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)',
}}
>
<HStack spacing={2}>
<BarChart3 className="w-4 h-4" />
<Text>统计</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
{/* 模型选择 */}
<TabPanel p={4}>
<VStack spacing={3} align="stretch">
{AVAILABLE_MODELS.map((model, idx) => (
<motion.div
key={model.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
>
<Card
cursor="pointer"
onClick={() => onModelChange(model.id)}
bg={
selectedModel === model.id
? 'rgba(139, 92, 246, 0.15)'
: 'rgba(255, 255, 255, 0.05)'
}
backdropFilter="blur(12px)"
borderWidth={2}
borderColor={
selectedModel === model.id ? 'purple.400' : 'rgba(255, 255, 255, 0.1)'
}
_hover={{
borderColor:
selectedModel === model.id ? 'purple.400' : 'rgba(255, 255, 255, 0.2)',
boxShadow:
selectedModel === model.id
? '0 8px 20px rgba(139, 92, 246, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
}}
transition="all 0.3s"
>
<CardBody p={3}>
<HStack align="start" spacing={3}>
<Box
p={2}
borderRadius="lg"
bgGradient={
selectedModel === model.id
? 'linear(to-br, purple.500, pink.500)'
: 'linear(to-br, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2))'
}
boxShadow={
selectedModel === model.id
? '0 4px 12px rgba(139, 92, 246, 0.4)'
: 'none'
}
>
{model.icon}
</Box>
<Box flex={1}>
<Text fontWeight="semibold" fontSize="sm" color="gray.100">
{model.name}
</Text>
<Text fontSize="xs" color="gray.400" mt={1}>
{model.description}
</Text>
</Box>
{selectedModel === model.id && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
>
<Check className="w-5 h-5" color="#A78BFA" />
</motion.div>
)}
</HStack>
</CardBody>
</Card>
</motion.div>
))}
</VStack>
</TabPanel>
{/* 工具选择 */}
<TabPanel p={4}>
<Accordion allowMultiple>
{Object.entries(TOOL_CATEGORIES).map(([category, tools], catIdx) => (
<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)"
>
{tools.filter((t) => selectedTools.includes(t.id)).length}/{tools.length}
</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}>
<motion.div flex={1} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
w="full"
variant="ghost"
onClick={() => onToolsChange(MCP_TOOLS.map((t) => t.id))}
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>
<motion.div flex={1} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
w="full"
variant="ghost"
onClick={() => onToolsChange([])}
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>
</HStack>
</TabPanel>
{/* 统计信息 */}
<TabPanel p={4}>
<VStack spacing={4} align="stretch">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody p={4}>
<Flex align="center" justify="space-between">
<Box>
<Text fontSize="xs" color="gray.400">
对话数
</Text>
<Text
fontSize="2xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, purple.400)"
bgClip="text"
>
{sessionsCount}
</Text>
</Box>
<MessageSquare
className="w-8 h-8"
color="#60A5FA"
style={{ opacity: 0.5 }}
/>
</Flex>
</CardBody>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody p={4}>
<Flex align="center" justify="space-between">
<Box>
<Text fontSize="xs" color="gray.400">
消息数
</Text>
<Text
fontSize="2xl"
fontWeight="bold"
bgGradient="linear(to-r, purple.400, pink.400)"
bgClip="text"
>
{messagesCount}
</Text>
</Box>
<Activity className="w-8 h-8" color="#C084FC" style={{ opacity: 0.5 }} />
</Flex>
</CardBody>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody p={4}>
<Flex align="center" justify="space-between">
<Box>
<Text fontSize="xs" color="gray.400">
已选工具
</Text>
<Text
fontSize="2xl"
fontWeight="bold"
bgGradient="linear(to-r, green.400, teal.400)"
bgClip="text"
>
{selectedTools.length}
</Text>
</Box>
<Code className="w-8 h-8" color="#34D399" style={{ opacity: 0.5 }} />
</Flex>
</CardBody>
</Card>
</motion.div>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
);
};
export default RightSidebar;

View File

@@ -0,0 +1,240 @@
// src/views/AgentChat/components/RightSidebar/index.tsx
// 右侧栏组件 - 配置中心(重构版本)
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Tooltip,
IconButton,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Badge,
HStack,
Text,
} from '@chakra-ui/react';
import {
Settings,
ChevronRight,
Cpu,
Code,
BarChart3,
} from 'lucide-react';
import { animations } from '../../constants/animations';
import ModelSelector from './ModelSelector';
import ToolSelector from './ToolSelector';
import Statistics from './Statistics';
/**
* RightSidebar 组件的 Props 类型
*/
interface RightSidebarProps {
/** 侧边栏是否展开 */
isOpen: boolean;
/** 关闭侧边栏回调 */
onClose: () => void;
/** 当前选中的模型 ID */
selectedModel: string;
/** 模型切换回调 */
onModelChange: (modelId: string) => void;
/** 已选工具 ID 列表 */
selectedTools: string[];
/** 工具选择变化回调 */
onToolsChange: (tools: string[]) => void;
/** 会话总数 */
sessionsCount: number;
/** 消息总数 */
messagesCount: number;
}
/**
* RightSidebar - 右侧栏组件(配置中心)(重构版本)
*
* 架构改进:
* - 模型选择提取到 ModelSelector 组件120 行)
* - 工具选择提取到 ToolSelector 组件200 行)
* - 统计信息提取到 Statistics 组件100 行)
* - 主组件只负责 Tabs 管理和布局组合150 行)
*
* 主组件职责:
* 1. 管理 Tabs 切换(模型/工具/统计)
* 2. 渲染标题栏(配置中心 + 收起按钮)
* 3. 组合三个子组件TabPanels
* 4. 处理侧边栏动画(滑入/滑出)
*/
const RightSidebar: React.FC<RightSidebarProps> = ({
isOpen,
onClose,
selectedModel,
onModelChange,
selectedTools,
onToolsChange,
sessionsCount,
messagesCount,
}) => {
return (
<AnimatePresence>
{isOpen && (
<motion.div
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInRight}
>
<Box
w="320px"
h="100%"
display="flex"
flexDirection="column"
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderLeft="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">
{/* 左侧:标题 */}
<HStack spacing={2}>
<Settings className="w-5 h-5" color="#C084FC" />
<Text
fontWeight="semibold"
bgGradient="linear(to-r, purple.300, pink.300)"
bgClip="text"
fontSize="md"
>
</Text>
</HStack>
{/* 右侧:收起按钮 */}
<Tooltip label="收起侧边栏">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ChevronRight 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>
</Box>
{/* ==================== Tab 面板 ==================== */}
<Box flex={1} overflowY="auto">
<Tabs colorScheme="purple" variant="line">
{/* Tab 标签栏 */}
<TabList px={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
{/* 模型 Tab */}
<Tab
color="gray.400"
_selected={{
color: 'purple.400',
borderColor: 'purple.500',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)',
}}
>
<HStack spacing={2}>
<Cpu className="w-4 h-4" />
<Text></Text>
</HStack>
</Tab>
{/* 工具 Tab */}
<Tab
color="gray.400"
_selected={{
color: 'purple.400',
borderColor: 'purple.500',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)',
}}
>
<HStack spacing={2}>
<Code className="w-4 h-4" />
<Text></Text>
{selectedTools.length > 0 && (
<Badge
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
borderRadius="full"
fontSize="xs"
px={2}
py={0.5}
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{selectedTools.length}
</Badge>
)}
</HStack>
</Tab>
{/* 统计 Tab */}
<Tab
color="gray.400"
_selected={{
color: 'purple.400',
borderColor: 'purple.500',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)',
}}
>
<HStack spacing={2}>
<BarChart3 className="w-4 h-4" />
<Text></Text>
</HStack>
</Tab>
</TabList>
{/* Tab 内容面板 */}
<TabPanels>
{/* 模型选择面板 */}
<TabPanel p={4}>
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
/>
</TabPanel>
{/* 工具选择面板 */}
<TabPanel p={4}>
<ToolSelector
selectedTools={selectedTools}
onToolsChange={onToolsChange}
/>
</TabPanel>
{/* 统计信息面板 */}
<TabPanel p={4}>
<Statistics
sessionsCount={sessionsCount}
messagesCount={messagesCount}
selectedToolsCount={selectedTools.length}
/>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
);
};
export default RightSidebar;

View File

@@ -0,0 +1,27 @@
// src/views/AgentChat/components/RightSidebar/types.ts
// RightSidebar 组件的 TypeScript 类型定义
/**
* 模型数据结构(来自 constants/models
*/
export interface Model {
id: string;
name: string;
description: string;
icon: React.ReactNode;
}
/**
* 工具数据结构(来自 constants/tools
*/
export interface Tool {
id: string;
name: string;
description: string;
icon: React.ReactNode;
}
/**
* 工具分类Map<分类名, 工具列表>
*/
export type ToolCategories = Record<string, Tool[]>;

View File

@@ -221,7 +221,7 @@ export const useAgentChat = ({
loadSessions();
}
} catch (error: any) {
logger.error('Agent chat error', error);
logger.error('useAgentChat', 'handleSendMessage', error as Error);
// 移除 "思考中" 和 "执行中" 消息
setMessages((prev) =>

View File

@@ -103,7 +103,7 @@ export const useAgentSessions = ({
setSessions(response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
logger.error('useAgentSessions', 'loadSessions', error as Error);
} finally {
setIsLoadingSessions(false);
}
@@ -135,7 +135,7 @@ export const useAgentSessions = ({
setMessages(formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
logger.error('useAgentSessions', 'loadSessionHistory', error as Error);
}
},
[setMessages]

View File

@@ -1,25 +1,27 @@
// src/views/AgentChat/utils/sessionUtils.js
// 会话管理工具函数
// src/views/AgentChat/utils/sessionUtils.ts
// 会话管理工具函数TypeScript 版本)
import type { Session, SessionGroups } from '../components/LeftSidebar/types';
/**
*
*
* @param {Array} sessions -
* @returns {Object} { today, yesterday, thisWeek, older }
* @param sessions -
* @returns { today, yesterday, thisWeek, older }
*
* @example
* const groups = groupSessionsByDate(sessions);
* console.log(groups.today); // 今天的会话
* console.log(groups.yesterday); // 昨天的会话
*/
export const groupSessionsByDate = (sessions) => {
export const groupSessionsByDate = (sessions: Session[]): SessionGroups => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const groups = {
const groups: SessionGroups = {
today: [],
yesterday: [],
thisWeek: [],
@@ -27,8 +29,8 @@ export const groupSessionsByDate = (sessions) => {
};
sessions.forEach((session) => {
const sessionDate = new Date(session.created_at || session.timestamp);
const daysDiff = Math.floor((today - sessionDate) / (1000 * 60 * 60 * 24));
const sessionDate = new Date(session.created_at || session.timestamp || Date.now());
const daysDiff = Math.floor((today.getTime() - sessionDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 0) {
groups.today.push(session);

View File

@@ -0,0 +1,814 @@
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

@@ -45,7 +45,7 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import { DateClickArg } from '@fullcalendar/interaction';
import { EventClickArg } from '@fullcalendar/common';
import type { EventClickArg } from '@fullcalendar/core';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';

View File

@@ -20,6 +20,7 @@ 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';
@@ -376,6 +377,10 @@ export default function HomePage() {
</SimpleGrid>
</VStack>
</Box>
{/* Midjourney风格英雄区域 */}
<MidjourneyHeroSection />
</VStack>
</Container>
</Box>