Merge branch 'feature_2025/251117_pref' into feature_2025/251121_h5UI

* feature_2025/251117_pref: (159 commits)
  feat: UI调整
  feat: 将滚动事件移东到组件内部
  feat: 去掉背景组件
  feat: 拆分左侧栏、中间聊天区、右侧栏组件, Hooks 提取
  feat: 简化主组件 index.js - 使用组件组合方式重构
  feat: 创建 ChatArea 组件(含 MessageRenderer、ExecutionStepsDisplay 子组件)
  feat:拆分工具函数
  feat: 拆分BackgroundEffects 背景渐变装饰层
  feat: RightSidebar (~420 行) - 模型/工具/统计 Tab 面板(单文件)
  feat:  LeftSidebar (~280 行) - 对话历史列表 + 用户信息卡片
  feat: 修复bug
  pref:移除黑夜模式
  feat: 修复警告
  feat: 提取常量配置
  feat: 修复ts报错
  feat:  StockChartModal.tsx 替换 KLine 实现
  update pay function
  update pay function
  update pay function
  update pay function
  ...
This commit is contained in:
zdl
2025-11-24 16:32:24 +08:00
159 changed files with 22541 additions and 11876 deletions

View File

@@ -0,0 +1,313 @@
# Agent Chat - 超炫酷 AI 投研助手
> 🎨 基于 **Hero UI** (NextUI) 构建的现代化 AI 聊天界面,模仿 Google AI Studio 风格
## ✨ 设计亮点
### 🚀 技术栈
- **Hero UI** - 现代化 React UI 组件库NextUI 的继任者)
- **Framer Motion 12** - 物理动画引擎(已升级!)
- **Tailwind CSS** - 原子化 CSS 框架Hero UI 内置)
- **Lucide Icons** - 现代化图标库
- **Chakra UI Toast** - 通知提示(待迁移到 Hero UI Toast
### 🎨 视觉特性
1. **毛玻璃效果Glassmorphism**
- 侧边栏和顶栏采用半透明毛玻璃质感
- `backdrop-blur-xl` + 渐变背景
- 深色模式完美支持
2. **流畅动画Framer Motion 12 物理引擎)**
- ✨ 侧边栏 **Spring 弹性动画**滑入/滑出stiffness: 300, damping: 30
- ✨ 消息 **淡入上移** + **错开延迟**staggerChildren: 0.05
- ✨ 按钮 **悬停缩放**1.05x + **点击压缩**0.95x
- ✨ AI 头像 **360度持续旋转**duration: 3s, linear
- ✨ 快捷问题卡片 **退出动画**AnimatePresence
3. **渐变色设计**
- 标题:蓝到紫渐变
- 用户消息气泡:蓝到紫渐变
- 发送按钮:蓝到紫渐变
- AI 头像:紫到粉渐变
4. **响应式布局**
- 三栏式设计(左侧历史 + 中间聊天 + 右侧配置)
- 侧边栏可折叠
- 暗黑模式支持
- 集成主导航栏MainLayout
### 🎯 核心功能
#### 1. 左侧历史面板
- ✅ 会话列表展示(带搜索)
- ✅ 新建对话
- ✅ 切换会话
- ✅ 会话元信息(时间、消息数)
- ✅ 用户信息展示
- ✅ 折叠/展开动画
#### 2. 中间聊天区域
- ✅ 消息流展示(用户/AI
- ✅ AI 思考状态(脉冲动画)
- ✅ 消息操作(复制、点赞、点踩)
- ✅ 执行步骤详情(可折叠 Accordion
- ✅ 快捷问题按钮2x2 网格)
- ✅ 键盘快捷键Enter 发送Shift+Enter 换行)
- ✅ 自动滚动到底部
- ✅ Hero UI ScrollShadow 组件
#### 3. 右侧配置面板
- ✅ Tabs 切换(模型 / 工具 / 统计)
- ✅ 模型选择3 个模型,卡片式)
- ✅ 工具选择Checkbox 多选)
- ✅ 统计信息(会话数、消息数、工具数)
## 🔧 使用方法
### 访问路径
```
/agent-chat
```
### 模型选择
| 模型 | 图标 | 描述 | 适用场景 |
|------|------|------|----------|
| **Kimi K2 Thinking** | 🧠 | 深度思考模型 | 复杂分析、深度研究 |
| **Kimi K2** | ⚡ | 快速响应模型 | 简单查询、快速问答 |
| **DeepMoney** | 📈 | 金融专业模型 | 金融数据分析 |
### 工具选择
| 工具 | 功能 |
|------|------|
| 📰 新闻搜索 | 搜索最新财经新闻 |
| 📈 涨停分析 | 分析涨停股票 |
| 💾 概念板块 | 查询概念板块信息 |
| 📚 研报搜索 | 搜索研究报告 |
| 📊 路演信息 | 查询路演活动 |
### 快捷键
| 快捷键 | 功能 |
|--------|------|
| `Enter` | 发送消息 |
| `Shift + Enter` | 换行 |
## 📦 Hero UI 组件使用
### 核心组件
```javascript
import {
Button, // 按钮
Card, // 卡片
Input, // 输入框
Avatar, // 头像
Chip, // 标签
Badge, // 徽章
Spinner, // 加载器
Tooltip, // 工具提示
Checkbox, // 复选框
Tabs, Tab, // 标签页
ScrollShadow, // 滚动阴影
Kbd, // 键盘按键
Accordion, // 手风琴
} from '@heroui/react';
```
### 特色功能
1. **isPressable / isHoverable**
```javascript
<Card isPressable isHoverable onPress={handleClick}>
内容
</Card>
```
2. **ScrollShadow**(自动滚动阴影)
```javascript
<ScrollShadow className="flex-1">
长内容...
</ScrollShadow>
```
3. **渐变背景Tailwind**
```javascript
<div className="bg-gradient-to-br from-gray-50 to-blue-50">
内容
</div>
```
4. **毛玻璃效果**
```javascript
<div className="bg-white/80 backdrop-blur-xl">
内容
</div>
```
## 🔌 API 集成
### 后端接口
#### 1. 获取会话列表
```http
GET /mcp/agent/sessions?user_id={user_id}&limit=50
```
#### 2. 获取会话历史
```http
GET /mcp/agent/history/{session_id}?limit=100
```
#### 3. 发送消息
```http
POST /mcp/agent/chat
Content-Type: application/json
{
"message": "用户问题",
"conversation_history": [],
"user_id": "user_id",
"session_id": "uuid或null",
"model": "kimi-k2-thinking",
"tools": ["search_news", "search_limit_up"]
}
```
## 🎨 Hero UI 特性
### 为什么选择 Hero UI
1. **基于 Tailwind CSS**
- 编译时 CSS零运行时开销
- 原子化类名,易于定制
- 深色模式内置支持
2. **基于 React Aria**
- 完整的无障碍支持
- 键盘导航内置
- ARIA 属性自动处理
3. **TypeScript 优先**
- 完整的类型支持
- 智能提示
4. **物理动画**
- 集成 Framer Motion
- 性能优化
5. **模块化架构**
- npm 包分发(非复制粘贴)
- 按需引入
- Tree-shaking 友好
### Hero UI vs Chakra UI
| 特性 | Hero UI | Chakra UI |
|------|---------|-----------|
| CSS 方案 | Tailwind CSS编译时 | Emotion运行时 |
| 包大小 | 更小Tree-shaking | 较大 |
| 性能 | 更快(无运行时 CSS | 较慢 |
| 定制性 | Tailwind 配置 | Theme 对象 |
| 学习曲线 | 需要熟悉 Tailwind | 纯 Props API |
| 组件数量 | 210+ | 100+ |
| 动画 | Framer Motion | Framer Motion |
| 无障碍 | React Aria | 自实现 |
## 📁 文件结构
```
src/views/AgentChat/
├── index.js # Hero UI 版本(当前)
├── index_old_chakra.js # Chakra UI 旧版本(备份)
└── README.md # 本文档
```
## 🎯 组件层次
```
AgentChat
├── MotionDiv (背景渐变)
├── LeftSidebar (历史会话)
│ ├── SearchInput (Hero UI Input)
│ ├── SessionList (Hero UI Card)
│ └── UserInfo (Hero UI Avatar)
├── ChatArea (中间区域)
│ ├── ChatHeader (Hero UI)
│ ├── MessageList (Hero UI ScrollShadow)
│ │ └── MessageRenderer
│ ├── QuickQuestions (Hero UI Button)
│ └── InputBox (Hero UI Input + Button)
└── RightSidebar (配置面板)
├── Tabs (Hero UI Tabs)
├── ModelSelector (Hero UI Card)
├── ToolSelector (Hero UI CheckboxGroup)
└── Statistics (Hero UI Badge)
```
## 🚀 性能优化
1. **代码分割**
- React.lazy() 懒加载
- 路由级别分割
2. **动画优化**
- Framer Motion 硬件加速
- AnimatePresence 动画退出
3. **Tailwind CSS**
- JIT 模式(即时编译,构建速度提升 50%
- 编译时生成 CSS
- 零运行时开销
- PurgeCSS 自动清理
4. **Hero UI**
- Tree-shaking 优化
- 按需引入组件
5. **构建优化craco.config.js**
- 文件系统缓存(二次构建提速 50-80%
- ESLint 插件移除(构建提速 20-30%
- 生产环境禁用 source map提速 40-60%
- 激进的代码分割策略(按库分离)
- Babel 缓存启用
## 🐛 已知问题
- ~~深色模式下某些颜色对比度不足~~ ✅ 已修复
- ~~会话删除功能需要后端 API 支持~~ ⏳ 待实现
## 📝 开发日志
### 2025-11-22
- ✅ 完成 Hero UI 迁移
- ✅ 实现三栏式布局
- ✅ 添加毛玻璃效果
- ✅ 集成 Framer Motion 动画
- ✅ 添加模型和工具选择功能
- ✅ 优化深色模式
## 🔮 未来计划
- [ ] 支持流式响应SSE
- [ ] Markdown 渲染react-markdown
- [ ] 代码高亮Prism.js
- [ ] 图片上传和分析
- [ ] 语音输入/输出
- [ ] 导出为 PDF/Word
- [ ] 分享对话链接
- [ ] 对话模板功能
## 📖 参考资源
- [Hero UI 官方文档](https://www.heroui.com/docs)
- [Framer Motion 文档](https://www.framer.com/motion/)
- [Tailwind CSS 文档](https://tailwindcss.com/docs)
- [Lucide Icons](https://lucide.dev/)
## 📄 许可证
本项目基于 Argon Dashboard Chakra PRO 模板开发。

View File

@@ -0,0 +1,117 @@
// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js
// 执行步骤显示组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Card,
CardBody,
Badge,
HStack,
VStack,
Flex,
Text,
} from '@chakra-ui/react';
import { Activity } from 'lucide-react';
/**
* ExecutionStepsDisplay - 执行步骤显示组件
*
* @param {Object} props
* @param {Array} props.steps - 执行步骤列表
* @param {Object} props.plan - 执行计划(可选)
* @returns {JSX.Element}
*/
const ExecutionStepsDisplay = ({ steps, plan }) => {
return (
<Accordion allowToggle>
<AccordionItem
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
borderRadius="lg"
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(10px)"
_hover={{
bg: 'rgba(255, 255, 255, 0.05)',
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" />
<Text color="gray.300" fontSize="sm">
执行详情
</Text>
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
variant="subtle"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{steps.length} 步骤
</Badge>
</HStack>
<AccordionIcon color="gray.400" />
</AccordionButton>
<AccordionPanel pb={4}>
<VStack spacing={2} align="stretch">
{steps.map((result, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
<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'
? 'linear(to-r, green.500, teal.500)'
: 'linear(to-r, red.500, orange.500)'
}
color="white"
variant="subtle"
boxShadow={
result.status === 'success'
? '0 2px 8px rgba(16, 185, 129, 0.3)'
: '0 2px 8px rgba(239, 68, 68, 0.3)'
}
>
{result.status}
</Badge>
</Flex>
<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}
</Text>
)}
</CardBody>
</Card>
</motion.div>
))}
</VStack>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default ExecutionStepsDisplay;

View File

@@ -0,0 +1,248 @@
// src/views/AgentChat/components/ChatArea/MessageRenderer.js
// 消息渲染器组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Card,
CardBody,
Avatar,
Badge,
Spinner,
Tooltip,
IconButton,
HStack,
Flex,
Text,
Box,
} from '@chakra-ui/react';
import { Cpu, User, Copy, ThumbsUp, ThumbsDown, File } from 'lucide-react';
import { MessageTypes } from '../../constants/messageTypes';
import ExecutionStepsDisplay from './ExecutionStepsDisplay';
/**
* MessageRenderer - 消息渲染器组件
*
* @param {Object} props
* @param {Object} props.message - 消息对象
* @param {string} props.userAvatar - 用户头像 URL
* @returns {JSX.Element|null}
*/
const MessageRenderer = ({ message, userAvatar }) => {
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="start" spacing={3} maxW="75%">
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<Card
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 8px 20px rgba(139, 92, 246, 0.4)"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="white" whiteSpace="pre-wrap">
{message.content}
</Text>
{message.files && message.files.length > 0 && (
<HStack mt={2} flexWrap="wrap" spacing={2}>
{message.files.map((file, idx) => (
<Badge
key={idx}
bg="rgba(255, 255, 255, 0.2)"
color="white"
display="flex"
alignItems="center"
gap={1}
>
<File className="w-3 h-3" />
{file.name}
</Badge>
))}
</HStack>
)}
</CardBody>
</Card>
</motion.div>
<Avatar
src={userAvatar}
icon={<User className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
/>
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="start" spacing={3} maxW="75%">
<Avatar
icon={<Cpu className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 12px rgba(236, 72, 153, 0.4)"
/>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(16px) saturate(180%)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody px={5} py={3}>
<HStack spacing={2}>
<Spinner
size="sm"
color="purple.500"
emptyColor="gray.700"
thickness="3px"
speed="0.65s"
/>
<Text fontSize="sm" color="gray.300">
{message.content}
</Text>
</HStack>
</CardBody>
</Card>
</motion.div>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="start" spacing={3} maxW="75%">
<Avatar
icon={<Cpu className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 12px rgba(236, 72, 153, 0.4)"
/>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(16px) saturate(180%)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
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 && (
<Box mt={3}>
<ExecutionStepsDisplay steps={message.stepResults} plan={message.plan} />
</Box>
)}
<Flex
align="center"
gap={2}
mt={3}
pt={3}
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
<Tooltip label="复制">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Copy className="w-4 h-4" />}
onClick={() => navigator.clipboard.writeText(message.content)}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
color: 'white',
bg: 'rgba(255, 255, 255, 0.1)',
}}
/>
</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" />}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
color: 'green.400',
bg: 'rgba(16, 185, 129, 0.1)',
boxShadow: '0 0 12px rgba(16, 185, 129, 0.3)',
}}
/>
</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" />}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
color: 'red.400',
bg: 'rgba(239, 68, 68, 0.1)',
boxShadow: '0 0 12px rgba(239, 68, 68, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Text fontSize="xs" color="gray.500" ml="auto">
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Flex>
</CardBody>
</Card>
</motion.div>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="center">
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }}>
<Card
bg="rgba(239, 68, 68, 0.1)"
backdropFilter="blur(16px)"
border="1px solid"
borderColor="rgba(239, 68, 68, 0.5)"
boxShadow="0 8px 32px 0 rgba(239, 68, 68, 0.37)"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="red.400">
{message.content}
</Text>
</CardBody>
</Card>
</motion.div>
</Flex>
);
default:
return null;
}
};
export default MessageRenderer;

View File

@@ -0,0 +1,477 @@
// 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,76 @@
// src/views/AgentChat/components/LeftSidebar/SessionCard.js
// 会话卡片组件
import React from 'react';
import { motion } from 'framer-motion';
import { Card, CardBody, Flex, Box, Text, Badge } from '@chakra-ui/react';
/**
* SessionCard - 会话卡片组件
*
* @param {Object} props
* @param {Object} props.session - 会话数据
* @param {boolean} props.isActive - 是否为当前选中的会话
* @param {Function} props.onPress - 点击回调函数
* @returns {JSX.Element}
*/
const SessionCard = ({ session, isActive, onPress }) => {
return (
<motion.div
whileHover={{ scale: 1.02, y: -4 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Card
cursor="pointer"
onClick={onPress}
bg={isActive ? 'rgba(139, 92, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)'}
backdropFilter="blur(12px)"
borderWidth={1}
borderColor={isActive ? 'purple.400' : 'rgba(255, 255, 255, 0.1)'}
_hover={{
bg: isActive ? 'rgba(139, 92, 246, 0.2)' : 'rgba(255, 255, 255, 0.08)',
borderColor: isActive ? 'purple.400' : 'rgba(255, 255, 255, 0.2)',
boxShadow: isActive
? '0 12px 24px rgba(139, 92, 246, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
}}
transition="all 0.3s"
>
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
{session.title || '新对话'}
</Text>
<Text fontSize="xs" color="gray.500" mt={1}>
{new Date(session.created_at || session.timestamp).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Box>
{session.message_count && (
<Badge
bgGradient={
isActive
? 'linear(to-r, blue.500, purple.500)'
: 'linear(to-r, gray.600, gray.700)'
}
color={isActive ? 'white' : 'gray.400'}
variant="subtle"
boxShadow={isActive ? '0 2px 8px rgba(139, 92, 246, 0.3)' : 'none'}
>
{session.message_count}
</Badge>
)}
</Flex>
</CardBody>
</Card>
</motion.div>
);
};
export default SessionCard;

View File

@@ -0,0 +1,314 @@
// src/views/AgentChat/components/LeftSidebar/index.js
// 左侧栏组件 - 对话历史列表
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Text,
Input,
Avatar,
Badge,
Spinner,
Tooltip,
IconButton,
HStack,
VStack,
Flex,
} from '@chakra-ui/react';
import { MessageSquare, Plus, Search, ChevronLeft } from 'lucide-react';
import { animations } from '../../constants/animations';
import { groupSessionsByDate } from '../../utils/sessionUtils';
import SessionCard from './SessionCard';
/**
* LeftSidebar - 左侧栏组件
*
* @param {Object} props
* @param {boolean} props.isOpen - 侧边栏是否展开
* @param {Function} props.onClose - 关闭侧边栏回调
* @param {Array} props.sessions - 会话列表
* @param {string|null} props.currentSessionId - 当前选中的会话 ID
* @param {Function} props.onSessionSwitch - 切换会话回调
* @param {Function} props.onNewSession - 新建会话回调
* @param {boolean} props.isLoadingSessions - 会话加载中状态
* @param {Object} props.user - 用户信息
* @returns {JSX.Element|null}
*/
const LeftSidebar = ({
isOpen,
onClose,
sessions,
currentSessionId,
onSessionSwitch,
onNewSession,
isLoadingSessions,
user,
}) => {
const [searchQuery, setSearchQuery] = useState('');
// 按日期分组会话
const sessionGroups = groupSessionsByDate(sessions);
// 搜索过滤
const filteredSessions = searchQuery
? sessions.filter(
(s) =>
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.session_id?.toLowerCase().includes(searchQuery.toLowerCase())
)
: sessions;
return (
<AnimatePresence>
{isOpen && (
<motion.div
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInLeft}
>
<Box
w="320px"
h="100%"
display="flex"
flexDirection="column"
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderRight="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="4px 0 24px rgba(0, 0, 0, 0.3)"
>
{/* 标题栏 */}
<Box p={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<HStack justify="space-between" mb={3}>
<HStack spacing={2}>
<MessageSquare className="w-5 h-5" color="#60A5FA" />
<Text
fontWeight="semibold"
bgGradient="linear(to-r, blue.300, purple.300)"
bgClip="text"
fontSize="md"
>
对话历史
</Text>
</HStack>
<HStack spacing={2}>
<Tooltip label="新建对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Plus className="w-4 h-4" />}
onClick={onNewSession}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(59, 130, 246, 0.2)',
borderColor: 'blue.400',
color: 'blue.300',
boxShadow: '0 0 12px rgba(59, 130, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="收起侧边栏">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ChevronLeft className="w-4 h-4" />}
onClick={onClose}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'purple.400',
color: 'white',
}}
/>
</motion.div>
</Tooltip>
</HStack>
</HStack>
{/* 搜索框 */}
<Box position="relative">
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)" zIndex={1}>
<Search className="w-4 h-4" color="#9CA3AF" />
</Box>
<Input
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="outline"
pl={10}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_placeholder={{ color: 'gray.500' }}
_hover={{
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
_focus={{
borderColor: 'purple.400',
boxShadow:
'0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
</Box>
</Box>
{/* 会话列表 */}
<Box flex={1} p={3} overflowY="auto">
{/* 按日期分组显示会话 */}
{sessionGroups.today.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
今天
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.today.map((session, idx) => (
<motion.div
key={session.session_id}
custom={idx}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<SessionCard
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
</motion.div>
))}
</VStack>
</Box>
)}
{sessionGroups.yesterday.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
昨天
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.yesterday.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{sessionGroups.thisWeek.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
本周
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.thisWeek.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{sessionGroups.older.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
更早
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.older.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{/* 加载状态 */}
{isLoadingSessions && (
<Flex justify="center" p={4}>
<Spinner
size="md"
color="purple.500"
emptyColor="gray.700"
thickness="3px"
speed="0.65s"
/>
</Flex>
)}
{/* 空状态 */}
{sessions.length === 0 && !isLoadingSessions && (
<VStack textAlign="center" py={8} color="gray.500" fontSize="sm" spacing={2}>
<MessageSquare className="w-8 h-8" style={{ opacity: 0.5, margin: '0 auto' }} />
<Text>还没有对话历史</Text>
<Text fontSize="xs">开始一个新对话吧</Text>
</VStack>
)}
</Box>
{/* 用户信息卡片 */}
<Box p={4} borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<HStack spacing={3}>
<Avatar
src={user?.avatar}
name={user?.nickname}
size="sm"
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
/>
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
{user?.nickname || '未登录'}
</Text>
<Badge
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
px={2}
py={0.5}
borderRadius="full"
fontSize="xs"
fontWeight="semibold"
textTransform="none"
>
{user?.subscription_type || 'free'}
</Badge>
</Box>
</HStack>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
);
};
export default LeftSidebar;

View File

@@ -0,0 +1,499 @@
// 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,101 @@
// src/views/AgentChat/constants/animations.ts
// Framer Motion 动画变体配置
import { Variants } from 'framer-motion';
/**
* Framer Motion 动画变体配置
* 用于 AgentChat 组件的各种动画效果
*/
export const animations: Record<string, Variants> = {
/**
* 左侧栏滑入动画Spring 物理引擎)
* 从左侧滑入,使用弹性动画
*/
slideInLeft: {
initial: { x: -320, opacity: 0 },
animate: {
x: 0,
opacity: 1,
transition: {
type: 'spring',
stiffness: 300,
damping: 30,
},
},
exit: {
x: -320,
opacity: 0,
transition: { duration: 0.2 },
},
},
/**
* 右侧栏滑入动画Spring 物理引擎)
* 从右侧滑入,使用弹性动画
*/
slideInRight: {
initial: { x: 320, opacity: 0 },
animate: {
x: 0,
opacity: 1,
transition: {
type: 'spring',
stiffness: 300,
damping: 30,
},
},
exit: {
x: 320,
opacity: 0,
transition: { duration: 0.2 },
},
},
/**
* 消息淡入上移动画
* 用于新消息出现时的动画效果
*/
fadeInUp: {
initial: { opacity: 0, y: 20 },
animate: {
opacity: 1,
y: 0,
transition: {
type: 'spring',
stiffness: 400,
damping: 25,
},
},
},
/**
* 错开动画项
* 用于列表项的错开出现效果
*/
staggerItem: {
initial: { opacity: 0, y: 10 },
animate: { opacity: 1, y: 0 },
},
/**
* 错开动画容器
* 用于包含多个子项的容器,使子项依次出现
*/
staggerContainer: {
animate: {
transition: {
staggerChildren: 0.05,
},
},
},
/**
* 按压缩放动画
* 用于按钮等交互元素的点击/悬停效果
*/
pressScale: {
whileTap: { scale: 0.95 },
whileHover: { scale: 1.05 },
},
};

View File

@@ -0,0 +1,22 @@
// src/views/AgentChat/constants/index.ts
// 常量配置统一导出
/**
* 统一导出所有常量配置模块
*
* 使用示例:
* ```typescript
* // 方式 1: 从子模块导入(推荐,按需引入)
* import { animations } from './constants/animations';
* import { MessageTypes } from './constants/messageTypes';
*
* // 方式 2: 从统一入口导入
* import { animations, MessageTypes, AVAILABLE_MODELS } from './constants';
* ```
*/
export * from './animations';
export * from './messageTypes';
export * from './models';
export * from './tools';
export * from './quickQuestions';

View File

@@ -0,0 +1,93 @@
// src/views/AgentChat/constants/messageTypes.ts
// 消息类型定义
/**
* 消息类型枚举
* 定义 Agent Chat 中所有可能的消息类型
*/
export enum MessageTypes {
/** 用户消息 */
USER = 'user',
/** Agent 思考中状态 */
AGENT_THINKING = 'agent_thinking',
/** Agent 执行计划 */
AGENT_PLAN = 'agent_plan',
/** Agent 执行步骤中 */
AGENT_EXECUTING = 'agent_executing',
/** Agent 最终回复 */
AGENT_RESPONSE = 'agent_response',
/** 错误消息 */
ERROR = 'error',
}
/**
* 文件附件接口
*/
export interface MessageFile {
/** 文件名称 */
name: string;
/** 文件大小(字节)*/
size: number;
/** MIME 类型 */
type: string;
/** 文件 URL本地或远程*/
url: string;
}
/**
* 消息基础接口
*/
export interface BaseMessage {
/** 消息唯一标识 */
id: string | number;
/** 消息类型 */
type: MessageTypes;
/** 消息内容 */
content: string;
/** 时间戳ISO 8601 格式)*/
timestamp: string;
}
/**
* 用户消息接口
*/
export interface UserMessage extends BaseMessage {
type: MessageTypes.USER;
/** 上传的文件附件 */
files?: MessageFile[];
}
/**
* Agent 消息接口
*/
export interface AgentMessage extends BaseMessage {
type:
| MessageTypes.AGENT_RESPONSE
| MessageTypes.AGENT_THINKING
| MessageTypes.AGENT_PLAN
| MessageTypes.AGENT_EXECUTING;
/** 执行计划JSON 对象)*/
plan?: any;
/** 执行步骤结果 */
stepResults?: Array<{
tool_name: string;
status: 'success' | 'error';
execution_time?: number;
error?: string;
}>;
/** 额外元数据 */
metadata?: any;
}
/**
* 错误消息接口
*/
export interface ErrorMessage extends BaseMessage {
type: MessageTypes.ERROR;
}
/**
* 消息联合类型
* 用于表示任意类型的消息
*/
export type Message = UserMessage | AgentMessage | ErrorMessage;

View File

@@ -0,0 +1,63 @@
// src/views/AgentChat/constants/models.ts
// 可用模型配置
import * as React from 'react';
import { Brain, Zap, TrendingUp } from 'lucide-react';
/**
* 模型配置接口
*/
export interface ModelConfig {
/** 模型唯一标识 */
id: string;
/** 模型显示名称 */
name: string;
/** 模型描述 */
description: string;
/** 模型图标React 元素)*/
icon: React.ReactNode;
/** 颜色主题 */
color: string;
}
/**
* 可用模型配置列表
* 包含所有可供用户选择的 AI 模型
*/
export const AVAILABLE_MODELS: ModelConfig[] = [
{
id: 'kimi-k2-thinking',
name: 'Kimi K2 Thinking',
description: '深度思考模型,适合复杂分析',
icon: React.createElement(Brain, { className: 'w-5 h-5' }),
color: 'purple',
},
{
id: 'kimi-k2',
name: 'Kimi K2',
description: '快速响应模型,适合简单查询',
icon: React.createElement(Zap, { className: 'w-5 h-5' }),
color: 'blue',
},
{
id: 'deepmoney',
name: 'DeepMoney',
description: '金融专业模型',
icon: React.createElement(TrendingUp, { className: 'w-5 h-5' }),
color: 'green',
},
];
/**
* 默认选中的模型 ID
*/
export const DEFAULT_MODEL_ID = 'kimi-k2-thinking';
/**
* 根据 ID 查找模型配置
* @param modelId 模型 ID
* @returns 模型配置对象,未找到则返回 undefined
*/
export const findModelById = (modelId: string): ModelConfig | undefined => {
return AVAILABLE_MODELS.find((model) => model.id === modelId);
};

View File

@@ -0,0 +1,23 @@
// src/views/AgentChat/constants/quickQuestions.ts
// 快捷问题配置
/**
* 快捷问题配置接口
*/
export interface QuickQuestion {
/** 问题文本 */
text: string;
/** 表情符号 */
emoji: string;
}
/**
* 预设快捷问题列表
* 用于在聊天界面初始状态显示,帮助用户快速开始对话
*/
export const quickQuestions: QuickQuestion[] = [
{ text: '今日涨停板块分析', emoji: '🔥' },
{ text: '新能源概念机会', emoji: '⚡' },
{ text: '半导体行业动态', emoji: '💾' },
{ text: '本周热门研报', emoji: '📊' },
];

View File

@@ -0,0 +1,249 @@
// src/views/AgentChat/constants/tools.ts
// MCP 工具配置
import * as React from 'react';
import {
Globe,
Newspaper,
Activity,
PieChart,
FileText,
BarChart3,
LineChart,
TrendingUp,
Calendar,
BookOpen,
Briefcase,
DollarSign,
Search,
Users,
} from 'lucide-react';
/**
* 工具类别枚举
*/
export enum ToolCategory {
NEWS = '新闻资讯',
CONCEPT = '概念板块',
LIMIT_UP = '涨停分析',
RESEARCH = '研报路演',
STOCK_DATA = '股票数据',
USER_DATA = '用户数据',
}
/**
* MCP 工具配置接口
*/
export interface MCPTool {
/** 工具唯一标识 */
id: string;
/** 工具显示名称 */
name: string;
/** 工具图标React 元素)*/
icon: React.ReactNode;
/** 工具类别 */
category: ToolCategory;
/** 工具描述 */
description: string;
}
/**
* MCP 工具完整配置列表
* 包含所有可供 Agent 调用的工具
*/
export const MCP_TOOLS: MCPTool[] = [
// ==================== 新闻搜索类 ====================
{
id: 'search_news',
name: '全球新闻搜索',
icon: React.createElement(Globe, { className: 'w-4 h-4' }),
category: ToolCategory.NEWS,
description: '搜索全球新闻,支持关键词和日期过滤',
},
{
id: 'search_china_news',
name: '中国新闻搜索',
icon: React.createElement(Newspaper, { className: 'w-4 h-4' }),
category: ToolCategory.NEWS,
description: 'KNN语义搜索中国新闻',
},
{
id: 'search_medical_news',
name: '医疗健康新闻',
icon: React.createElement(Activity, { className: 'w-4 h-4' }),
category: ToolCategory.NEWS,
description: '医药、医疗设备、生物技术新闻',
},
// ==================== 概念板块类 ====================
{
id: 'search_concepts',
name: '概念板块搜索',
icon: React.createElement(PieChart, { className: 'w-4 h-4' }),
category: ToolCategory.CONCEPT,
description: '搜索股票概念板块及相关股票',
},
{
id: 'get_concept_details',
name: '概念详情',
icon: React.createElement(FileText, { className: 'w-4 h-4' }),
category: ToolCategory.CONCEPT,
description: '获取概念板块详细信息',
},
{
id: 'get_stock_concepts',
name: '股票概念',
icon: React.createElement(BarChart3, { className: 'w-4 h-4' }),
category: ToolCategory.CONCEPT,
description: '查询股票相关概念板块',
},
{
id: 'get_concept_statistics',
name: '概念统计',
icon: React.createElement(LineChart, { className: 'w-4 h-4' }),
category: ToolCategory.CONCEPT,
description: '涨幅榜、跌幅榜、活跃榜等',
},
// ==================== 涨停分析类 ====================
{
id: 'search_limit_up_stocks',
name: '涨停股票搜索',
icon: React.createElement(TrendingUp, { className: 'w-4 h-4' }),
category: ToolCategory.LIMIT_UP,
description: '搜索涨停股票,支持多条件筛选',
},
{
id: 'get_daily_stock_analysis',
name: '涨停日报',
icon: React.createElement(Calendar, { className: 'w-4 h-4' }),
category: ToolCategory.LIMIT_UP,
description: '每日涨停股票分析报告',
},
// ==================== 研报路演类 ====================
{
id: 'search_research_reports',
name: '研报搜索',
icon: React.createElement(BookOpen, { className: 'w-4 h-4' }),
category: ToolCategory.RESEARCH,
description: '搜索研究报告,支持语义搜索',
},
{
id: 'search_roadshows',
name: '路演活动',
icon: React.createElement(Briefcase, { className: 'w-4 h-4' }),
category: ToolCategory.RESEARCH,
description: '上市公司路演、投资者交流活动',
},
// ==================== 股票数据类 ====================
{
id: 'get_stock_basic_info',
name: '股票基本信息',
icon: React.createElement(FileText, { className: 'w-4 h-4' }),
category: ToolCategory.STOCK_DATA,
description: '公司名称、行业、主营业务等',
},
{
id: 'get_stock_financial_index',
name: '财务指标',
icon: React.createElement(DollarSign, { className: 'w-4 h-4' }),
category: ToolCategory.STOCK_DATA,
description: 'EPS、ROE、营收增长率等',
},
{
id: 'get_stock_trade_data',
name: '交易数据',
icon: React.createElement(BarChart3, { className: 'w-4 h-4' }),
category: ToolCategory.STOCK_DATA,
description: '价格、成交量、涨跌幅等',
},
{
id: 'get_stock_balance_sheet',
name: '资产负债表',
icon: React.createElement(PieChart, { className: 'w-4 h-4' }),
category: ToolCategory.STOCK_DATA,
description: '资产、负债、所有者权益',
},
{
id: 'get_stock_cashflow',
name: '现金流量表',
icon: React.createElement(LineChart, { className: 'w-4 h-4' }),
category: ToolCategory.STOCK_DATA,
description: '经营、投资、筹资现金流',
},
{
id: 'search_stocks_by_criteria',
name: '条件选股',
icon: React.createElement(Search, { className: 'w-4 h-4' }),
category: ToolCategory.STOCK_DATA,
description: '按行业、地区、市值筛选',
},
{
id: 'get_stock_comparison',
name: '股票对比',
icon: React.createElement(BarChart3, { className: 'w-4 h-4' }),
category: ToolCategory.STOCK_DATA,
description: '多只股票财务指标对比',
},
// ==================== 用户数据类 ====================
{
id: 'get_user_watchlist',
name: '自选股列表',
icon: React.createElement(Users, { className: 'w-4 h-4' }),
category: ToolCategory.USER_DATA,
description: '用户关注的股票及行情',
},
{
id: 'get_user_following_events',
name: '关注事件',
icon: React.createElement(Activity, { className: 'w-4 h-4' }),
category: ToolCategory.USER_DATA,
description: '用户关注的重大事件',
},
];
/**
* 按类别分组的工具配置
* 用于在 UI 中按类别展示工具
*/
export const TOOL_CATEGORIES: Record<ToolCategory, MCPTool[]> = {
[ToolCategory.NEWS]: MCP_TOOLS.filter((t) => t.category === ToolCategory.NEWS),
[ToolCategory.CONCEPT]: MCP_TOOLS.filter((t) => t.category === ToolCategory.CONCEPT),
[ToolCategory.LIMIT_UP]: MCP_TOOLS.filter((t) => t.category === ToolCategory.LIMIT_UP),
[ToolCategory.RESEARCH]: MCP_TOOLS.filter((t) => t.category === ToolCategory.RESEARCH),
[ToolCategory.STOCK_DATA]: MCP_TOOLS.filter((t) => t.category === ToolCategory.STOCK_DATA),
[ToolCategory.USER_DATA]: MCP_TOOLS.filter((t) => t.category === ToolCategory.USER_DATA),
};
/**
* 默认选中的工具 ID 列表
* 这些工具在页面初始化时自动选中
*/
export const DEFAULT_SELECTED_TOOLS: string[] = [
'search_news',
'search_china_news',
'search_concepts',
'search_limit_up_stocks',
'search_research_reports',
];
/**
* 根据 ID 查找工具配置
* @param toolId 工具 ID
* @returns 工具配置对象,未找到则返回 undefined
*/
export const findToolById = (toolId: string): MCPTool | undefined => {
return MCP_TOOLS.find((tool) => tool.id === toolId);
};
/**
* 根据类别获取工具列表
* @param category 工具类别
* @returns 该类别下的所有工具
*/
export const getToolsByCategory = (category: ToolCategory): MCPTool[] => {
return TOOL_CATEGORIES[category] || [];
};

View File

@@ -0,0 +1,34 @@
// src/views/AgentChat/hooks/index.ts
// 自定义 Hooks 统一导出
/**
* 自定义 Hooks 统一入口
*
* 使用示例:
* ```typescript
* // 方式 1: 从统一入口导入(推荐)
* import { useAgentChat, useAgentSessions, useFileUpload, useAutoScroll } from './hooks';
*
* // 方式 2: 从单个文件导入
* import { useAgentChat } from './hooks/useAgentChat';
* ```
*/
export { useAutoScroll } from './useAutoScroll';
export { useFileUpload } from './useFileUpload';
export type { UploadedFile, UseFileUploadReturn } from './useFileUpload';
export { useAgentSessions } from './useAgentSessions';
export type {
Session,
User,
UseAgentSessionsParams,
UseAgentSessionsReturn,
} from './useAgentSessions';
export { useAgentChat } from './useAgentChat';
export type {
ToastFunction,
UseAgentChatParams,
UseAgentChatReturn,
} from './useAgentChat';

View File

@@ -0,0 +1,289 @@
// src/views/AgentChat/hooks/useAgentChat.ts
// 消息处理 Hook - 发送消息、处理响应、错误处理
import { useState, useCallback } from 'react';
import type { Dispatch, SetStateAction, KeyboardEvent } from 'react';
import axios from 'axios';
import { logger } from '@utils/logger';
import { MessageTypes, type Message } from '../constants/messageTypes';
import type { UploadedFile } from './useFileUpload';
import type { User } from './useAgentSessions';
/**
* Toast 通知函数类型(来自 Chakra UI
*/
export interface ToastFunction {
(options: {
title: string;
description?: string;
status: 'success' | 'error' | 'warning' | 'info';
duration?: number;
isClosable?: boolean;
}): void;
}
/**
* useAgentChat Hook 参数
*/
export interface UseAgentChatParams {
/** 当前用户信息 */
user: User | null;
/** 当前会话 ID */
currentSessionId: string | null;
/** 设置当前会话 ID */
setCurrentSessionId: Dispatch<SetStateAction<string | null>>;
/** 选中的 AI 模型 */
selectedModel: string;
/** 选中的工具列表 */
selectedTools: string[];
/** 已上传文件列表 */
uploadedFiles: UploadedFile[];
/** 清空已上传文件 */
clearFiles: () => void;
/** Toast 通知函数 */
toast: ToastFunction;
/** 重新加载会话列表(发送消息成功后调用) */
loadSessions: () => Promise<void>;
}
/**
* useAgentChat Hook 返回值
*/
export interface UseAgentChatReturn {
/** 消息列表 */
messages: Message[];
/** 设置消息列表 */
setMessages: Dispatch<SetStateAction<Message[]>>;
/** 输入框内容 */
inputValue: string;
/** 设置输入框内容 */
setInputValue: Dispatch<SetStateAction<string>>;
/** 是否正在处理消息 */
isProcessing: boolean;
/** 发送消息 */
handleSendMessage: () => Promise<void>;
/** 键盘事件处理Enter 发送) */
handleKeyPress: (e: KeyboardEvent<HTMLInputElement>) => void;
/** 添加消息到列表 */
addMessage: (message: Partial<Message>) => void;
}
/**
* useAgentChat Hook
*
* 处理消息发送、AI 响应、错误处理逻辑
*
* @param params - UseAgentChatParams
* @returns UseAgentChatReturn
*
* @example
* ```tsx
* const {
* messages,
* inputValue,
* setInputValue,
* isProcessing,
* handleSendMessage,
* handleKeyPress,
* } = useAgentChat({
* user,
* currentSessionId,
* setCurrentSessionId,
* selectedModel,
* selectedTools,
* uploadedFiles,
* clearFiles,
* toast,
* loadSessions,
* });
* ```
*/
export const useAgentChat = ({
user,
currentSessionId,
setCurrentSessionId,
selectedModel,
selectedTools,
uploadedFiles,
clearFiles,
toast,
loadSessions,
}: UseAgentChatParams): UseAgentChatReturn => {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
/**
* 添加消息到列表
*/
const addMessage = useCallback((message: Partial<Message>) => {
setMessages((prev) => [
...prev,
{
id: Date.now() + Math.random(),
timestamp: new Date().toISOString(),
...message,
} as Message,
]);
}, []);
/**
* 发送消息到后端 API
*/
const handleSendMessage = useCallback(async () => {
if (!inputValue.trim() || isProcessing) return;
// 创建用户消息
const userMessage: Partial<Message> = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
};
addMessage(userMessage);
const userInput = inputValue;
// 清空输入框和文件
setInputValue('');
clearFiles();
setIsProcessing(true);
try {
// 显示 "思考中" 状态
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
});
// 调用后端 API
const response = await axios.post('/mcp/agent/chat', {
message: userInput,
conversation_history: messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((m) => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
user_id: user?.id || 'anonymous',
user_nickname: user?.nickname || '匿名用户',
user_avatar: user?.avatar || '',
subscription_type: user?.subscription_type || 'free',
session_id: currentSessionId,
model: selectedModel,
tools: selectedTools,
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
});
// 移除 "思考中" 消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
if (response.data.success) {
const data = response.data;
// 更新会话 ID如果是新会话
if (data.session_id && !currentSessionId) {
setCurrentSessionId(data.session_id);
}
// 显示执行计划(如果有)
if (data.plan) {
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data.plan,
});
}
// 显示执行步骤(如果有)
if (data.steps && data.steps.length > 0) {
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: data.plan,
stepResults: data.steps,
});
}
// 移除 "执行中" 消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终回复
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.final_answer || data.message || '处理完成',
plan: data.plan,
stepResults: data.steps,
metadata: data.metadata,
});
// 重新加载会话列表
loadSessions();
}
} catch (error: any) {
logger.error('Agent chat error', error);
// 移除 "思考中" 和 "执行中" 消息
setMessages((prev) =>
prev.filter(
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
)
);
// 显示错误消息
const errorMessage = error.response?.data?.error || error.message || '处理失败';
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${errorMessage}`,
});
// 显示 Toast 通知
toast({
title: '处理失败',
description: errorMessage,
status: 'error',
duration: 5000,
});
} finally {
setIsProcessing(false);
}
}, [
inputValue,
isProcessing,
uploadedFiles,
messages,
user,
currentSessionId,
selectedModel,
selectedTools,
addMessage,
clearFiles,
setCurrentSessionId,
loadSessions,
toast,
]);
/**
* 键盘事件处理Enter 发送Shift+Enter 换行)
*/
const handleKeyPress = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
},
[handleSendMessage]
);
return {
messages,
setMessages,
inputValue,
setInputValue,
isProcessing,
handleSendMessage,
handleKeyPress,
addMessage,
};
};

View File

@@ -0,0 +1,189 @@
// src/views/AgentChat/hooks/useAgentSessions.ts
// 会话管理 Hook - 加载、切换、创建会话
import { useState, useEffect, useCallback } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import axios from 'axios';
import { logger } from '@utils/logger';
import { MessageTypes, type Message } from '../constants/messageTypes';
/**
* 会话数据结构
*/
export interface Session {
session_id: string;
title?: string;
created_at?: string;
timestamp?: string;
message_count?: number;
}
/**
* 用户信息(从 AuthContext 传入)
*/
export interface User {
id: string;
nickname?: string;
avatar?: string;
subscription_type?: string;
}
/**
* useAgentSessions Hook 参数
*/
export interface UseAgentSessionsParams {
/** 当前用户信息 */
user: User | null;
/** 消息列表 setter用于创建新会话时设置欢迎消息 */
setMessages: Dispatch<SetStateAction<Message[]>>;
}
/**
* useAgentSessions Hook 返回值
*/
export interface UseAgentSessionsReturn {
/** 会话列表 */
sessions: Session[];
/** 当前选中的会话 ID */
currentSessionId: string | null;
/** 设置当前会话 ID */
setCurrentSessionId: Dispatch<SetStateAction<string | null>>;
/** 是否正在加载会话列表 */
isLoadingSessions: boolean;
/** 加载会话列表 */
loadSessions: () => Promise<void>;
/** 切换到指定会话 */
switchSession: (sessionId: string) => void;
/** 创建新会话(显示欢迎消息) */
createNewSession: () => void;
/** 加载指定会话的历史消息 */
loadSessionHistory: (sessionId: string) => Promise<void>;
}
/**
* useAgentSessions Hook
*
* 管理会话列表、会话切换、新建会话逻辑
*
* @param params - UseAgentSessionsParams
* @returns UseAgentSessionsReturn
*
* @example
* ```tsx
* const {
* sessions,
* currentSessionId,
* isLoadingSessions,
* switchSession,
* createNewSession,
* } = useAgentSessions({ user, setMessages });
* ```
*/
export const useAgentSessions = ({
user,
setMessages,
}: UseAgentSessionsParams): UseAgentSessionsReturn => {
const [sessions, setSessions] = useState<Session[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
/**
* 加载用户的会话列表
*/
const loadSessions = useCallback(async () => {
if (!user?.id) return;
setIsLoadingSessions(true);
try {
const response = await axios.get('/mcp/agent/sessions', {
params: { user_id: user.id, limit: 50 },
});
if (response.data.success) {
setSessions(response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
} finally {
setIsLoadingSessions(false);
}
}, [user?.id]);
/**
* 加载指定会话的历史消息
*/
const loadSessionHistory = useCallback(
async (sessionId: string) => {
if (!sessionId) return;
try {
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
params: { limit: 100 },
});
if (response.data.success) {
const history = response.data.data;
const formattedMessages: Message[] = history.map((msg: any, idx: number) => ({
id: `${sessionId}-${idx}`,
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
content: msg.message,
plan: msg.plan ? JSON.parse(msg.plan) : null,
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
timestamp: msg.timestamp,
}));
setMessages(formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
}
},
[setMessages]
);
/**
* 切换到指定会话
*/
const switchSession = useCallback(
(sessionId: string) => {
setCurrentSessionId(sessionId);
loadSessionHistory(sessionId);
},
[loadSessionHistory]
);
/**
* 创建新会话(清空消息,显示欢迎消息)
*/
const createNewSession = useCallback(() => {
setCurrentSessionId(null);
setMessages([
{
id: Date.now(),
type: MessageTypes.AGENT_RESPONSE,
content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索`,
timestamp: new Date().toISOString(),
},
]);
}, [user?.nickname, setMessages]);
/**
* 组件挂载时加载会话列表并创建新会话
*/
useEffect(() => {
loadSessions();
createNewSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
return {
sessions,
currentSessionId,
setCurrentSessionId,
isLoadingSessions,
loadSessions,
switchSession,
createNewSession,
loadSessionHistory,
};
};

View File

@@ -0,0 +1,38 @@
// src/views/AgentChat/hooks/useAutoScroll.ts
// 自动滚动 Hook - 消息列表自动滚动到底部
import { useEffect, useRef } from 'react';
import type { Message } from '../constants/messageTypes';
/**
* useAutoScroll Hook
*
* 监听消息列表变化,自动滚动到底部
*
* @param messages - 消息列表
* @returns messagesEndRef - 消息列表底部引用(需要绑定到消息列表末尾的 div
*
* @example
* ```tsx
* const { messagesEndRef } = useAutoScroll(messages);
*
* return (
* <VStack>
* {messages.map(msg => <MessageCard key={msg.id} message={msg} />)}
* <div ref={messagesEndRef} />
* </VStack>
* );
* ```
*/
export const useAutoScroll = (messages: Message[]) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// 平滑滚动到底部
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return {
messagesEndRef,
};
};

View File

@@ -0,0 +1,131 @@
// src/views/AgentChat/hooks/useFileUpload.ts
// 文件上传 Hook - 处理文件选择、预览、删除
import { useState, useRef } from 'react';
import type { ChangeEvent, RefObject } from 'react';
/**
* 上传文件数据结构
*/
export interface UploadedFile {
/** 文件名 */
name: string;
/** 文件大小(字节) */
size: number;
/** 文件 MIME 类型 */
type: string;
/** 文件预览 URL使用 URL.createObjectURL 创建) */
url: string;
}
/**
* useFileUpload Hook 返回值
*/
export interface UseFileUploadReturn {
/** 已上传文件列表 */
uploadedFiles: UploadedFile[];
/** 文件输入框引用(用于触发文件选择) */
fileInputRef: RefObject<HTMLInputElement>;
/** 处理文件选择事件 */
handleFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
/** 删除指定文件 */
removeFile: (index: number) => void;
/** 清空所有文件 */
clearFiles: () => void;
}
/**
* useFileUpload Hook
*
* 处理文件上传相关逻辑(选择、预览、删除)
*
* @returns UseFileUploadReturn
*
* @example
* ```tsx
* const { uploadedFiles, fileInputRef, handleFileSelect, removeFile } = useFileUpload();
*
* return (
* <>
* <input
* ref={fileInputRef}
* type="file"
* multiple
* accept="image/*,.pdf,.doc,.docx,.txt"
* onChange={handleFileSelect}
* style={{ display: 'none' }}
* />
* <Button onClick={() => fileInputRef.current?.click()}>上传文件</Button>
* {uploadedFiles.map((file, idx) => (
* <Tag key={idx}>
* {file.name}
* <TagCloseButton onClick={() => removeFile(idx)} />
* </Tag>
* ))}
* </>
* );
* ```
*/
export const useFileUpload = (): UseFileUploadReturn => {
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
/**
* 处理文件选择事件
*/
const handleFileSelect = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const fileData: UploadedFile[] = files.map((file) => ({
name: file.name,
size: file.size,
type: file.type,
// 创建本地预览 URL实际上传时需要转换为 base64 或上传到服务器)
url: URL.createObjectURL(file),
}));
setUploadedFiles((prev) => [...prev, ...fileData]);
// 清空 input value允许重复选择同一文件
if (event.target) {
event.target.value = '';
}
};
/**
* 删除指定索引的文件
*/
const removeFile = (index: number) => {
setUploadedFiles((prev) => {
// 释放 URL.createObjectURL 创建的内存
const file = prev[index];
if (file?.url) {
URL.revokeObjectURL(file.url);
}
return prev.filter((_, i) => i !== index);
});
};
/**
* 清空所有文件
*/
const clearFiles = () => {
// 释放所有 URL 内存
uploadedFiles.forEach((file) => {
if (file.url) {
URL.revokeObjectURL(file.url);
}
});
setUploadedFiles([]);
};
return {
uploadedFiles,
fileInputRef,
handleFileSelect,
removeFile,
clearFiles,
};
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,816 @@
// src/views/AgentChat/index.js
// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本
// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Button,
Input,
Avatar,
Badge,
Divider,
Spinner,
Tooltip,
Checkbox,
CheckboxGroup,
Kbd,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
useToast,
VStack,
HStack,
Text,
Flex,
IconButton,
useColorMode,
Card,
CardBody,
Tag,
TagLabel,
TagCloseButton,
} from '@chakra-ui/react';
import { useAuth } from '@contexts/AuthContext';
import { logger } from '@utils/logger';
import axios from 'axios';
// 图标 - 使用 Lucide Icons
import {
Send,
Plus,
Search,
MessageSquare,
Trash2,
MoreVertical,
RefreshCw,
Download,
Cpu,
User,
Zap,
Clock,
Settings,
ChevronLeft,
ChevronRight,
Activity,
Code,
Database,
TrendingUp,
FileText,
BookOpen,
Menu,
X,
Check,
Circle,
Maximize2,
Minimize2,
Copy,
ThumbsUp,
ThumbsDown,
Sparkles,
Brain,
Rocket,
Paperclip,
Image as ImageIcon,
File,
Calendar,
Globe,
DollarSign,
Newspaper,
BarChart3,
PieChart,
LineChart,
Briefcase,
Users,
} from 'lucide-react';
// 常量配置 - 从 TypeScript 模块导入
import { MessageTypes } from './constants/messageTypes';
import { DEFAULT_MODEL_ID } from './constants/models';
import { DEFAULT_SELECTED_TOOLS } from './constants/tools';
// 拆分后的子组件
import BackgroundEffects from './components/BackgroundEffects';
import LeftSidebar from './components/LeftSidebar';
import ChatArea from './components/ChatArea';
import RightSidebar from './components/RightSidebar';
/**
* Agent Chat - 主组件HeroUI v3 深色主题)
*
* 注意:所有常量配置已提取到 constants/ 目录:
* - animations: constants/animations.ts
* - MessageTypes: constants/messageTypes.ts
* - AVAILABLE_MODELS: constants/models.ts
* - MCP_TOOLS, TOOL_CATEGORIES: constants/tools.ts
* - quickQuestions: constants/quickQuestions.ts
*/
const AgentChat = () => {
const { user } = useAuth();
const toast = useToast();
const { setColorMode } = useColorMode();
// 会话管理
const [sessions, setSessions] = useState([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
// 消息管理
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
// UI 状态
const [searchQuery, setSearchQuery] = useState('');
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID);
const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true);
// 文件上传
const [uploadedFiles, setUploadedFiles] = useState([]);
const fileInputRef = useRef(null);
// Refs
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
// ==================== 启用深色模式 ====================
useEffect(() => {
// 为 AgentChat 页面强制启用深色模式
setColorMode('dark');
document.documentElement.classList.add('dark');
return () => {
// 组件卸载时不移除,让其他页面自己控制
// document.documentElement.classList.remove('dark');
};
}, [setColorMode]);
// ==================== API 调用函数 ====================
const loadSessions = async () => {
if (!user?.id) return;
setIsLoadingSessions(true);
try {
const response = await axios.get('/mcp/agent/sessions', {
params: { user_id: user.id, limit: 50 },
});
if (response.data.success) {
setSessions(response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
} finally {
setIsLoadingSessions(false);
}
};
const loadSessionHistory = async (sessionId) => {
if (!sessionId) return;
try {
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
params: { limit: 100 },
});
if (response.data.success) {
const history = response.data.data;
const formattedMessages = history.map((msg, idx) => ({
id: `${sessionId}-${idx}`,
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
content: msg.message,
plan: msg.plan ? JSON.parse(msg.plan) : null,
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
timestamp: msg.timestamp,
}));
setMessages(formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
}
};
const createNewSession = () => {
setCurrentSessionId(null);
setMessages([
{
id: Date.now(),
type: MessageTypes.AGENT_RESPONSE,
content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索`,
timestamp: new Date().toISOString(),
},
]);
};
const switchSession = (sessionId) => {
setCurrentSessionId(sessionId);
loadSessionHistory(sessionId);
};
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
};
addMessage(userMessage);
const userInput = inputValue;
setInputValue('');
setUploadedFiles([]);
setIsProcessing(true);
try {
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
const response = await axios.post('/mcp/agent/chat', {
message: userInput,
conversation_history: messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((m) => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
user_id: user?.id || 'anonymous',
user_nickname: user?.nickname || '匿名用户',
user_avatar: user?.avatar || '',
subscription_type: user?.subscription_type || 'free',
session_id: currentSessionId,
model: selectedModel,
tools: selectedTools,
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
});
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
if (response.data.success) {
const data = response.data;
if (data.session_id && !currentSessionId) {
setCurrentSessionId(data.session_id);
}
if (data.plan) {
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data.plan,
timestamp: new Date().toISOString(),
});
}
if (data.steps && data.steps.length > 0) {
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: data.plan,
stepResults: data.steps,
timestamp: new Date().toISOString(),
});
}
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.final_answer || data.message || '处理完成',
plan: data.plan,
stepResults: data.steps,
metadata: data.metadata,
timestamp: new Date().toISOString(),
});
loadSessions();
}
} catch (error) {
logger.error('Agent chat error', error);
setMessages((prev) =>
prev.filter(
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
)
);
const errorMessage = error.response?.data?.error || error.message || '处理失败';
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${errorMessage}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: errorMessage,
status: 'error',
duration: 5000,
});
} finally {
setIsProcessing(false);
}
};
// 文件上传处理
const handleFileSelect = (event) => {
const files = Array.from(event.target.files || []);
const fileData = files.map(file => ({
name: file.name,
size: file.size,
type: file.type,
// 实际上传时需要转换为 base64 或上传到服务器
url: URL.createObjectURL(file),
}));
setUploadedFiles(prev => [...prev, ...fileData]);
};
const removeFile = (index) => {
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
};
const addMessage = (message) => {
setMessages((prev) => [
...prev,
{
id: Date.now() + Math.random(),
...message,
},
]);
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
useEffect(() => {
loadSessions();
createNewSession();
}, [user]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<Box flex={1} bg="gray.900">
<Flex h="100%" overflow="hidden" position="relative">
{/* 背景渐变装饰 */}
<BackgroundEffects />
{/* 左侧栏 */}
<LeftSidebar
isOpen={isLeftSidebarOpen}
onClose={() => setIsLeftSidebarOpen(false)}
sessions={sessions}
currentSessionId={currentSessionId}
onSessionSwitch={switchSession}
onNewSession={createNewSession}
isLoadingSessions={isLoadingSessions}
user={user}
/>
{/* 中间聊天区 */}
<ChatArea
messages={messages}
inputValue={inputValue}
onInputChange={setInputValue}
isProcessing={isProcessing}
onSendMessage={handleSendMessage}
onKeyPress={handleKeyPress}
uploadedFiles={uploadedFiles}
onFileSelect={handleFileSelect}
onFileRemove={removeFile}
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={() => setIsLeftSidebarOpen(true)}
onToggleRightSidebar={() => setIsRightSidebarOpen(true)}
onNewSession={createNewSession}
userAvatar={user?.avatar}
messagesEndRef={messagesEndRef}
inputRef={inputRef}
fileInputRef={fileInputRef}
/>
{/* 右侧栏 - 深色配置中心 */}
<AnimatePresence>
{isRightSidebarOpen && (
<motion.div
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInRight}
>
<Box
w="320px"
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={() => setIsRightSidebarOpen(false)}
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>
<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={() => setSelectedModel(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={setSelectedTools}
>
<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={() => setSelectedTools(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={() => setSelectedTools([])}
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"
>
{sessions.length}
</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"
>
{messages.length}
</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>
</Flex>
</Box>
);
};
export default AgentChat;

View File

@@ -1,53 +0,0 @@
// src/views/AgentChat/index.js
// Agent聊天页面
import React from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
useColorModeValue,
} from '@chakra-ui/react';
import { ChatInterfaceV2 } from '../../components/ChatBot';
/**
* Agent聊天页面
* 提供基于MCP的AI助手对话功能
*/
const AgentChat = () => {
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
return (
<Box minH="calc(100vh - 200px)" bg={bgColor} py={8}>
<Container maxW="container.xl" h="100%">
<VStack spacing={6} align="stretch" h="100%">
{/* 页面标题 */}
<Box>
<Heading size="lg" mb={2}>AI投资助手</Heading>
<Text color="gray.600" fontSize="sm">
基于MCP协议的智能投资顾问支持股票查询新闻搜索概念分析等多种功能
</Text>
</Box>
{/* 聊天界面 */}
<Box
flex="1"
bg={cardBg}
borderRadius="xl"
boxShadow="xl"
overflow="hidden"
h="calc(100vh - 300px)"
minH="600px"
>
<ChatInterfaceV2 />
</Box>
</VStack>
</Container>
</Box>
);
};
export default AgentChat;

View File

@@ -1,857 +0,0 @@
// src/views/AgentChat/index_v3.js
// Agent聊天页面 V3 - 带左侧会话列表和用户信息集成
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Flex,
VStack,
HStack,
Text,
Input,
IconButton,
Button,
Avatar,
Heading,
Divider,
Spinner,
Badge,
useColorModeValue,
useToast,
Progress,
Fade,
Collapse,
useDisclosure,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuList,
MenuItem,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Tooltip,
} from '@chakra-ui/react';
import {
FiSend,
FiSearch,
FiPlus,
FiMessageSquare,
FiTrash2,
FiMoreVertical,
FiRefreshCw,
FiDownload,
FiCpu,
FiUser,
FiZap,
FiClock,
} from 'react-icons/fi';
import { useAuth } from '@contexts/AuthContext';
import { PlanCard } from '@components/ChatBot/PlanCard';
import { StepResultCard } from '@components/ChatBot/StepResultCard';
import { logger } from '@utils/logger';
import axios from 'axios';
/**
* Agent消息类型
*/
const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
/**
* Agent聊天页面 V3
*/
const AgentChatV3 = () => {
const { user } = useAuth(); // 获取当前用户信息
const toast = useToast();
// 会话相关状态
const [sessions, setSessions] = useState([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [isLoadingSessions, setIsLoadingSessions] = useState(true);
// 消息相关状态
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [currentProgress, setCurrentProgress] = useState(0);
// UI 状态
const [searchQuery, setSearchQuery] = useState('');
const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true });
// Refs
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
// 颜色主题
const bgColor = useColorModeValue('gray.50', 'gray.900');
const sidebarBg = useColorModeValue('white', 'gray.800');
const chatBg = useColorModeValue('white', 'gray.800');
const inputBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const activeBg = useColorModeValue('blue.50', 'blue.900');
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
// ==================== 会话管理函数 ====================
// 加载会话列表
const loadSessions = async () => {
if (!user?.id) return;
setIsLoadingSessions(true);
try {
const response = await axios.get('/mcp/agent/sessions', {
params: { user_id: user.id, limit: 50 },
});
if (response.data.success) {
setSessions(response.data.data);
logger.info('会话列表加载成功', response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
toast({
title: '加载失败',
description: '无法加载会话列表',
status: 'error',
duration: 3000,
});
} finally {
setIsLoadingSessions(false);
}
};
// 加载会话历史
const loadSessionHistory = async (sessionId) => {
if (!sessionId) return;
try {
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
params: { limit: 100 },
});
if (response.data.success) {
const history = response.data.data;
// 将历史记录转换为消息格式
const formattedMessages = history.map((msg, idx) => ({
id: `${sessionId}-${idx}`,
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
content: msg.message,
plan: msg.plan ? JSON.parse(msg.plan) : null,
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
timestamp: msg.timestamp,
}));
setMessages(formattedMessages);
logger.info('会话历史加载成功', formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
toast({
title: '加载失败',
description: '无法加载会话历史',
status: 'error',
duration: 3000,
});
}
};
// 创建新会话
const createNewSession = () => {
setCurrentSessionId(null);
setMessages([
{
id: Date.now(),
type: MessageTypes.AGENT_RESPONSE,
content: `你好${user?.nickname || ''}我是价小前北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`,
timestamp: new Date().toISOString(),
},
]);
};
// 切换会话
const switchSession = (sessionId) => {
setCurrentSessionId(sessionId);
loadSessionHistory(sessionId);
};
// 删除会话需要后端API支持
const deleteSession = async (sessionId) => {
// TODO: 实现删除会话的后端API
toast({
title: '删除会话',
description: '此功能尚未实现',
status: 'info',
duration: 2000,
});
};
// ==================== 消息处理函数 ====================
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 添加消息
const addMessage = (message) => {
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
};
// 发送消息
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
// 权限检查
if (user?.id !== 'max') {
toast({
title: '权限不足',
description: '「价小前投研」功能目前仅对特定用户开放。如需使用,请联系管理员。',
status: 'warning',
duration: 5000,
isClosable: true,
});
return;
}
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
};
addMessage(userMessage);
const userInput = inputValue;
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
let currentPlan = null;
let stepResults = [];
try {
// 1. 显示思考状态
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
// 2. 调用后端API非流式
const response = await axios.post('/mcp/agent/chat', {
message: userInput,
conversation_history: messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((m) => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
user_id: user?.id || 'anonymous',
user_nickname: user?.nickname || '匿名用户',
user_avatar: user?.avatar || '',
session_id: currentSessionId,
});
// 移除思考消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
if (response.data.success) {
const data = response.data;
// 更新 session_id如果是新会话
if (data.session_id && !currentSessionId) {
setCurrentSessionId(data.session_id);
}
// 显示执行计划
if (data.plan) {
currentPlan = data.plan;
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data.plan,
timestamp: new Date().toISOString(),
});
setCurrentProgress(30);
}
// 显示执行步骤
if (data.steps && data.steps.length > 0) {
stepResults = data.steps;
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: currentPlan,
stepResults: stepResults,
timestamp: new Date().toISOString(),
});
setCurrentProgress(70);
}
// 移除执行中消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.final_answer || data.message || '处理完成',
plan: currentPlan,
stepResults: stepResults,
metadata: data.metadata,
timestamp: new Date().toISOString(),
});
setCurrentProgress(100);
// 重新加载会话列表
loadSessions();
}
} catch (error) {
logger.error('Agent chat error', error);
// 移除思考/执行中消息
setMessages((prev) =>
prev.filter(
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
)
);
const errorMessage = error.response?.data?.error || error.message || '处理失败';
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${errorMessage}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsProcessing(false);
setCurrentProgress(0);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
createNewSession();
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`)
.join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
// ==================== 初始化 ====================
useEffect(() => {
if (user) {
loadSessions();
createNewSession();
}
}, [user]);
// ==================== 渲染 ====================
// 快捷问题
const quickQuestions = [
'全面分析贵州茅台这只股票',
'今日涨停股票有哪些亮点',
'新能源概念板块的投资机会',
'半导体行业最新动态',
];
// 筛选会话
const filteredSessions = sessions.filter(
(session) =>
!searchQuery ||
session.last_message?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Flex h="calc(100vh - 80px)" bg={bgColor}>
{/* 左侧会话列表 */}
<Collapse in={isSidebarOpen} animateOpacity>
<Box
w="300px"
bg={sidebarBg}
borderRight="1px"
borderColor={borderColor}
h="100%"
display="flex"
flexDirection="column"
>
{/* 侧边栏头部 */}
<Box p={4} borderBottom="1px" borderColor={borderColor}>
<Button
leftIcon={<FiPlus />}
colorScheme="blue"
w="100%"
onClick={createNewSession}
size="sm"
>
新建对话
</Button>
{/* 搜索框 */}
<InputGroup mt={3} size="sm">
<InputLeftElement pointerEvents="none">
<FiSearch color="gray.300" />
</InputLeftElement>
<Input
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
</Box>
{/* 会话列表 */}
<VStack
flex="1"
overflowY="auto"
spacing={0}
align="stretch"
css={{
'&::-webkit-scrollbar': { width: '6px' },
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '3px',
},
}}
>
{isLoadingSessions ? (
<Flex justify="center" align="center" h="200px">
<Spinner />
</Flex>
) : filteredSessions.length === 0 ? (
<Flex justify="center" align="center" h="200px" direction="column">
<FiMessageSquare size={32} color="gray" />
<Text mt={2} fontSize="sm" color="gray.500">
{searchQuery ? '没有找到匹配的对话' : '暂无对话记录'}
</Text>
</Flex>
) : (
filteredSessions.map((session) => (
<Box
key={session.session_id}
p={3}
cursor="pointer"
bg={currentSessionId === session.session_id ? activeBg : 'transparent'}
_hover={{ bg: hoverBg }}
borderBottom="1px"
borderColor={borderColor}
onClick={() => switchSession(session.session_id)}
>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex="1">
<Text fontSize="sm" fontWeight="medium" noOfLines={2}>
{session.last_message || '新对话'}
</Text>
<HStack spacing={2} fontSize="xs" color="gray.500">
<FiClock size={12} />
<Text>
{new Date(session.last_timestamp).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
})}
</Text>
<Badge colorScheme="blue" fontSize="xx-small">
{session.message_count}
</Badge>
</HStack>
</VStack>
<Menu>
<MenuButton
as={IconButton}
icon={<FiMoreVertical />}
size="xs"
variant="ghost"
onClick={(e) => e.stopPropagation()}
/>
<MenuList>
<MenuItem
icon={<FiTrash2 />}
color="red.500"
onClick={(e) => {
e.stopPropagation();
deleteSession(session.session_id);
}}
>
删除对话
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Box>
))
)}
</VStack>
{/* 用户信息 */}
<Box p={4} borderTop="1px" borderColor={borderColor}>
<HStack spacing={3}>
<Avatar size="sm" name={user?.nickname} src={user?.avatar} />
<VStack align="start" spacing={0} flex="1">
<Text fontSize="sm" fontWeight="medium">
{user?.nickname || '未登录'}
</Text>
<Text fontSize="xs" color="gray.500">
{user?.id || 'anonymous'}
</Text>
</VStack>
</HStack>
</Box>
</Box>
</Collapse>
{/* 主聊天区域 */}
<Flex flex="1" direction="column" h="100%">
{/* 聊天头部 */}
<Box bg={chatBg} borderBottom="1px" borderColor={borderColor} px={6} py={4}>
<HStack justify="space-between">
<HStack spacing={4}>
<IconButton
icon={<FiMessageSquare />}
size="sm"
variant="ghost"
aria-label="切换侧边栏"
onClick={toggleSidebar}
/>
<Avatar size="md" bg="blue.500" icon={<FiCpu fontSize="1.5rem" />} />
<VStack align="start" spacing={0}>
<Heading size="md">价小前投研</Heading>
<HStack>
<Badge colorScheme="green" fontSize="xs">
<HStack spacing={1}>
<FiZap size={10} />
<span>智能分析</span>
</HStack>
</Badge>
<Text fontSize="xs" color="gray.500">
多步骤深度研究
</Text>
</HStack>
</VStack>
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
</HStack>
</HStack>
{/* 进度条 */}
{isProcessing && (
<Progress
value={currentProgress}
size="xs"
colorScheme="blue"
mt={3}
borderRadius="full"
isAnimated
/>
)}
</Box>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={6}
py={4}
css={{
'&::-webkit-scrollbar': { width: '8px' },
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={4} align="stretch">
{messages.map((message) => (
<Fade in key={message.id}>
<MessageRenderer message={message} userAvatar={user?.avatar} />
</Fade>
))}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题 */}
{messages.length <= 2 && !isProcessing && (
<Box px={6} py={3} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Text fontSize="xs" color="gray.500" mb={2}>
💡 试试这些问题
</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="sm"
variant="outline"
colorScheme="blue"
fontSize="xs"
onClick={() => {
setInputValue(question);
inputRef.current?.focus();
}}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
{/* 输入框 */}
<Box px={6} py={4} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入你的问题,我会进行深度分析..."
bg={inputBg}
border="1px"
borderColor={borderColor}
_focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }}
mr={2}
disabled={isProcessing}
size="lg"
/>
<IconButton
icon={isProcessing ? <Spinner size="sm" /> : <FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isProcessing}
disabled={!inputValue.trim() || isProcessing}
size="lg"
/>
</Flex>
</Box>
</Flex>
</Flex>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message, userAvatar }) => {
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="flex-start" maxW="75%">
<Box
bg={userBubbleBg}
color="white"
px={4}
py={3}
borderRadius="lg"
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
</Box>
<Avatar size="sm" src={userAvatar} icon={<FiUser fontSize="1rem" />} />
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="purple.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<HStack>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="purple.600">
{message.content}
</Text>
</HStack>
</Box>
</HStack>
</Flex>
);
case MessageTypes.AGENT_PLAN:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="blue.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1">
<PlanCard plan={message.plan} stepResults={[]} />
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_EXECUTING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="orange.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1" spacing={3}>
<PlanCard plan={message.plan} stepResults={message.stepResults} />
{message.stepResults?.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="green.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1" spacing={3}>
{/* 最终总结 */}
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
{/* 元数据 */}
{message.metadata && (
<HStack mt={3} spacing={4} fontSize="xs" color="gray.500">
<Text>总步骤: {message.metadata.total_steps}</Text>
<Text> {message.metadata.successful_steps}</Text>
{message.metadata.failed_steps > 0 && (
<Text> {message.metadata.failed_steps}</Text>
)}
<Text>耗时: {message.metadata.total_execution_time?.toFixed(1)}s</Text>
</HStack>
)}
</Box>
{/* 执行详情(可选) */}
{message.plan && message.stepResults && message.stepResults.length > 0 && (
<VStack align="stretch" spacing={2}>
<Divider />
<Text fontSize="xs" fontWeight="bold" color="gray.500">
📊 执行详情点击展开查看
</Text>
{message.stepResults.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
)}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="red.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg="red.50"
color="red.700"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor="red.200"
>
<Text fontSize="sm">{message.content}</Text>
</Box>
</HStack>
</Flex>
);
default:
return null;
}
};
export default AgentChatV3;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
// src/views/AgentChat/utils/sessionUtils.js
// 会话管理工具函数
/**
* 按日期分组会话列表
*
* @param {Array} sessions - 会话列表
* @returns {Object} 分组后的会话对象 { today, yesterday, thisWeek, older }
*
* @example
* const groups = groupSessionsByDate(sessions);
* console.log(groups.today); // 今天的会话
* console.log(groups.yesterday); // 昨天的会话
*/
export const groupSessionsByDate = (sessions) => {
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 = {
today: [],
yesterday: [],
thisWeek: [],
older: [],
};
sessions.forEach((session) => {
const sessionDate = new Date(session.created_at || session.timestamp);
const daysDiff = Math.floor((today - sessionDate) / (1000 * 60 * 60 * 24));
if (daysDiff === 0) {
groups.today.push(session);
} else if (daysDiff === 1) {
groups.yesterday.push(session);
} else if (daysDiff <= 7) {
groups.thisWeek.push(session);
} else {
groups.older.push(session);
}
});
return groups;
};

View File

@@ -52,6 +52,7 @@ const StockListItem = ({
const [isDescExpanded, setIsDescExpanded] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalChartType, setModalChartType] = useState('timeline'); // 跟踪用户点击的图表类型
const handleViewDetail = () => {
const stockCode = stock.stock_code.split('.')[0];
@@ -203,6 +204,7 @@ const StockListItem = ({
bg="rgba(59, 130, 246, 0.1)"
onClick={(e) => {
e.stopPropagation();
setModalChartType('timeline'); // 设置为分时图
setIsModalOpen(true);
}}
cursor="pointer"
@@ -245,6 +247,7 @@ const StockListItem = ({
bg="rgba(168, 85, 247, 0.1)"
onClick={(e) => {
e.stopPropagation();
setModalChartType('daily'); // 设置为日K线
setIsModalOpen(true);
}}
cursor="pointer"
@@ -385,6 +388,7 @@ const StockListItem = ({
stock={stock}
eventTime={eventTime}
size="6xl"
initialChartType={modalChartType} // 传递用户点击的图表类型
/>
)}
</>

View File

@@ -1,7 +1,7 @@
import React, { useRef, useMemo, useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Particles from 'react-tsparticles';
import { loadSlim } from 'tsparticles-slim';
import Particles from '@tsparticles/react';
import { loadSlim } from '@tsparticles/slim';
import {
Box,
Container,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -819,7 +819,9 @@ const StockCard = ({ stock, idx }) => {
</ModalHeader>
<ModalCloseButton color="#eacd76" top={3} right={3} />
<ModalBody p={6} bg={useColorModeValue('white', 'gray.800')}>
<Box fontSize="md" color="#222" lineHeight={1.9} whiteSpace="pre-line" dangerouslySetInnerHTML={{ __html: (stock.summary || '').replace(/<br\s*\/?>(\s*)/g, '\n').replace(/\n{2,}/g, '\n').replace(/\n/g, '<br/>') }} />
<Box fontSize="md" color="#222" lineHeight={1.9} whiteSpace="pre-wrap">
{(stock.summary || '').replace(/<br\s*\/?>/gi, '\n')}
</Box>
</ModalBody>
<ModalFooter bg={useColorModeValue('white', 'gray.800')} justifyContent="center">
<Button onClick={onClose} colorScheme="yellow" borderRadius="md" px={8} fontWeight="bold">关闭</Button>

View File

@@ -50,21 +50,24 @@ import {
Treemap,
Area, AreaChart,
} from 'recharts';
// 词云库 - 支持两种实现
import { Wordcloud } from '@visx/wordcloud';
import { scaleLog } from '@visx/scale';
import { Text as VisxText } from '@visx/text';
import ReactECharts from 'echarts-for-react';
import 'echarts-wordcloud';
// 颜色配置
const CHART_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD',
'#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB'
];
// 词云颜色
// 词云颜色常量
const WORDCLOUD_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
// 词云组件(使用 @visx/wordcloud,兼容 React 18
const WordCloud = ({ data }) => {
// ==================== 词云组件实现 1: @visx/wordcloud ====================
// 使用 SVG 渲染React 18 原生支持,配置灵活
const VisxWordCloud = ({ data }) => {
const [dimensions, setDimensions] = useState({ width: 0, height: 400 });
const containerRef = useRef(null);
@@ -99,7 +102,7 @@ const WordCloud = ({ data }) => {
}
const words = data.slice(0, 100).map(item => ({
text: item.name || item.text,
name: item.name || item.text,
value: item.value || item.count || 1
}));
@@ -151,6 +154,85 @@ const WordCloud = ({ data }) => {
);
};
// ==================== 词云组件实现 2: ECharts Wordcloud ====================
// 使用 Canvas 渲染内置交互效果tooltip、emphasis配置简单
const EChartsWordCloud = ({ data }) => {
if (!data || data.length === 0) {
return (
<Center h="400px">
<VStack>
<Text color="gray.500">暂无词云数据</Text>
</VStack>
</Center>
);
}
const words = data.slice(0, 100).map(item => ({
name: item.name || item.text,
value: item.value || item.count || 1
}));
const option = {
tooltip: {
show: true
},
series: [{
type: 'wordCloud',
shape: 'circle',
left: 'center',
top: 'center',
width: '100%',
height: '100%',
sizeRange: [16, 80],
rotationRange: [-90, 0],
rotationStep: 90,
gridSize: 8,
drawOutOfBound: false,
layoutAnimation: true,
textStyle: {
fontFamily: 'Microsoft YaHei, sans-serif',
fontWeight: 'bold',
color: function () {
return WORDCLOUD_COLORS[Math.floor(Math.random() * WORDCLOUD_COLORS.length)];
}
},
emphasis: {
focus: 'self',
textStyle: {
textShadowBlur: 10,
textShadowColor: '#333'
}
},
data: words
}]
};
return (
<ReactECharts
option={option}
style={{ height: '400px', width: '100%' }}
opts={{ renderer: 'canvas' }}
/>
);
};
// ==================== 词云组件包装器 ====================
// 统一接口,支持切换两种实现方式
const WordCloud = ({ data, engine = 'echarts' }) => {
if (!data || data.length === 0) {
return (
<Center h="400px">
<VStack>
<Text color="gray.500">暂无词云数据</Text>
</VStack>
</Center>
);
}
// 根据 engine 参数选择实现方式
return engine === 'visx' ? <VisxWordCloud data={data} /> : <EChartsWordCloud data={data} />;
};
// 板块热力图组件
const SectorHeatMap = ({ data }) => {
if (!data) return null;

383
src/views/Profile/index.js Normal file
View File

@@ -0,0 +1,383 @@
/**
* 个人中心页面
* 包含用户信息、积分系统、交易记录等
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Grid,
GridItem,
Heading,
Text,
VStack,
HStack,
Avatar,
Button,
Card,
CardBody,
CardHeader,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Badge,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Icon,
useToast,
Spinner,
Divider,
} from '@chakra-ui/react';
import {
Wallet,
TrendingUp,
Gift,
History,
Award,
Calendar,
DollarSign,
Activity,
} from 'lucide-react';
import { useAuth } from '@contexts/AuthContext';
import { getUserAccount, claimDailyBonus } from '@services/predictionMarketService.api';
import { forumColors } from '@theme/forumTheme';
const ProfilePage = () => {
const toast = useToast();
const { user } = useAuth();
// 状态管理
const [account, setAccount] = useState(null);
const [loading, setLoading] = useState(true);
const [claiming, setClaiming] = useState(false);
// 加载用户积分账户
useEffect(() => {
const fetchAccount = async () => {
if (!user) return;
try {
setLoading(true);
const response = await getUserAccount();
if (response.success) {
setAccount(response.data);
}
} catch (error) {
console.error('获取账户失败:', error);
toast({
title: '加载失败',
description: '无法加载账户信息',
status: 'error',
duration: 3000,
});
} finally {
setLoading(false);
}
};
fetchAccount();
}, [user, toast]);
// 领取每日奖励
const handleClaimDailyBonus = async () => {
try {
setClaiming(true);
const response = await claimDailyBonus();
if (response.success) {
toast({
title: '领取成功!',
description: `获得 ${response.data.bonus_amount} 积分`,
status: 'success',
duration: 3000,
});
// 刷新账户数据
const accountResponse = await getUserAccount();
if (accountResponse.success) {
setAccount(accountResponse.data);
}
}
} catch (error) {
toast({
title: '领取失败',
description: error.response?.data?.error || '今日奖励已领取或系统错误',
status: 'error',
duration: 3000,
});
} finally {
setClaiming(false);
}
};
if (loading) {
return (
<Box minH="80vh" display="flex" alignItems="center" justifyContent="center">
<VStack spacing="4">
<Spinner size="xl" color={forumColors.primary[500]} />
<Text color={forumColors.text.secondary}>加载中...</Text>
</VStack>
</Box>
);
}
return (
<Box bg={forumColors.background.main} minH="100vh" py="8">
<Container maxW="container.xl">
{/* 用户信息头部 */}
<Card
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
mb="6"
>
<CardBody>
<HStack spacing="6" align="start">
<Avatar
size="2xl"
name={user?.nickname || user?.username}
src={user?.avatar_url}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
<VStack align="start" spacing="3" flex="1">
<Heading size="lg" color={forumColors.text.primary}>
{user?.nickname || user?.username}
</Heading>
<HStack spacing="4">
<Badge
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
px="3"
py="1"
borderRadius="full"
>
<Icon as={Award} boxSize="14px" mr="1" />
会员
</Badge>
<Text fontSize="sm" color={forumColors.text.secondary}>
{user?.email}
</Text>
</HStack>
</VStack>
</HStack>
</CardBody>
</Card>
{/* 积分概览 */}
<Grid templateColumns={{ base: '1fr', md: 'repeat(4, 1fr)' }} gap="6" mb="6">
{/* 总余额 */}
<GridItem>
<Card
bg={forumColors.gradients.goldSubtle}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.gold}
>
<CardBody>
<Stat>
<StatLabel fontSize="sm" color={forumColors.text.secondary}>
<Icon as={Wallet} boxSize="16px" mr="1" />
总余额
</StatLabel>
<StatNumber fontSize="3xl" fontWeight="bold" color={forumColors.primary[500]}>
{account?.balance?.toFixed(0) || 0}
</StatNumber>
<StatHelpText color={forumColors.text.tertiary}>积分</StatHelpText>
</Stat>
</CardBody>
</Card>
</GridItem>
{/* 可用余额 */}
<GridItem>
<Card
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
>
<CardBody>
<Stat>
<StatLabel fontSize="sm" color={forumColors.text.secondary}>
<Icon as={DollarSign} boxSize="16px" mr="1" />
可用余额
</StatLabel>
<StatNumber fontSize="2xl" color={forumColors.text.primary}>
{account?.available_balance?.toFixed(0) || 0}
</StatNumber>
<StatHelpText color={forumColors.text.tertiary}>积分</StatHelpText>
</Stat>
</CardBody>
</Card>
</GridItem>
{/* 累计收益 */}
<GridItem>
<Card
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
>
<CardBody>
<Stat>
<StatLabel fontSize="sm" color={forumColors.text.secondary}>
<Icon as={TrendingUp} boxSize="16px" mr="1" />
累计收益
</StatLabel>
<StatNumber fontSize="2xl" color={forumColors.success[500]}>
+{account?.total_earned?.toFixed(0) || 0}
</StatNumber>
<StatHelpText>
<StatArrow type="increase" />
积分
</StatHelpText>
</Stat>
</CardBody>
</Card>
</GridItem>
{/* 累计消费 */}
<GridItem>
<Card
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
>
<CardBody>
<Stat>
<StatLabel fontSize="sm" color={forumColors.text.secondary}>
<Icon as={Activity} boxSize="16px" mr="1" />
累计消费
</StatLabel>
<StatNumber fontSize="2xl" color={forumColors.text.primary}>
{account?.total_spent?.toFixed(0) || 0}
</StatNumber>
<StatHelpText color={forumColors.text.tertiary}>积分</StatHelpText>
</Stat>
</CardBody>
</Card>
</GridItem>
</Grid>
{/* 每日签到 */}
<Card
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
mb="6"
>
<CardHeader>
<HStack justify="space-between">
<HStack spacing="2">
<Icon as={Gift} boxSize="20px" color={forumColors.primary[500]} />
<Heading size="md" color={forumColors.text.primary}>
每日签到
</Heading>
</HStack>
<Button
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
fontWeight="bold"
onClick={handleClaimDailyBonus}
isLoading={claiming}
loadingText="领取中..."
_hover={{ opacity: 0.9 }}
leftIcon={<Icon as={Calendar} />}
>
领取今日奖励
</Button>
</HStack>
</CardHeader>
<CardBody>
<VStack align="start" spacing="3">
<HStack spacing="2">
<Icon as={Calendar} boxSize="16px" color={forumColors.text.secondary} />
<Text fontSize="sm" color={forumColors.text.secondary}>
每日登录可领取 100 积分奖励
</Text>
</HStack>
{account?.last_daily_bonus_at && (
<Text fontSize="xs" color={forumColors.text.tertiary}>
上次领取时间{new Date(account.last_daily_bonus_at).toLocaleString('zh-CN')}
</Text>
)}
</VStack>
</CardBody>
</Card>
{/* 详细信息标签页 */}
<Card
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
>
<CardBody>
<Tabs colorScheme="yellow" variant="soft-rounded">
<TabList mb="4">
<Tab
_selected={{
bg: forumColors.gradients.goldPrimary,
color: forumColors.background.main,
}}
>
<Icon as={History} boxSize="16px" mr="2" />
交易记录
</Tab>
<Tab
_selected={{
bg: forumColors.gradients.goldPrimary,
color: forumColors.background.main,
}}
>
<Icon as={Activity} boxSize="16px" mr="2" />
积分明细
</Tab>
</TabList>
<TabPanels>
{/* 交易记录 */}
<TabPanel>
<Box>
<Text color={forumColors.text.secondary} textAlign="center" py="10">
暂无交易记录
</Text>
</Box>
</TabPanel>
{/* 积分明细 */}
<TabPanel>
<Box>
<Text color={forumColors.text.secondary} textAlign="center" py="10">
暂无积分明细
</Text>
</Box>
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
</Container>
</Box>
);
};
export default ProfilePage;

View File

@@ -40,6 +40,7 @@ import {
} from '@services/elasticsearchService';
import EventTimeline from './components/EventTimeline';
import CommentSection from './components/CommentSection';
import ImagePreviewModal from '@components/ImagePreviewModal';
const MotionBox = motion(Box);
@@ -53,6 +54,10 @@ const PostDetail = () => {
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(0);
// 图片预览相关状态
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false);
const [previewImageIndex, setPreviewImageIndex] = useState(0);
// 加载帖子数据
useEffect(() => {
const loadPostData = async () => {
@@ -91,6 +96,12 @@ const PostDetail = () => {
}
};
// 打开图片预览
const handleImageClick = (index) => {
setPreviewImageIndex(index);
setIsImagePreviewOpen(true);
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
@@ -272,6 +283,7 @@ const PostDetail = () => {
border="1px solid"
borderColor={forumColors.border.default}
cursor="pointer"
onClick={() => handleImageClick(index)}
_hover={{
transform: 'scale(1.05)',
boxShadow: forumColors.shadows.gold,
@@ -363,6 +375,14 @@ const PostDetail = () => {
</Box>
</SimpleGrid>
</Container>
{/* 图片预览弹窗 */}
<ImagePreviewModal
isOpen={isImagePreviewOpen}
onClose={() => setIsImagePreviewOpen(false)}
images={post?.images || []}
initialIndex={previewImageIndex}
/>
</Box>
);
};

View File

@@ -0,0 +1,652 @@
/**
* 预测话题详情页
* 展示预测市场的完整信息、交易、评论等
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Heading,
Text,
Button,
HStack,
VStack,
Flex,
Badge,
Avatar,
Icon,
Progress,
Divider,
useDisclosure,
useToast,
SimpleGrid,
} from '@chakra-ui/react';
import {
TrendingUp,
TrendingDown,
Crown,
Users,
Clock,
DollarSign,
ShoppingCart,
ArrowLeftRight,
CheckCircle2,
} from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { forumColors } from '@theme/forumTheme';
import { getTopicDetail, getUserAccount } from '@services/predictionMarketService.api';
import { useAuth } from '@contexts/AuthContext';
import TradeModal from './components/TradeModal';
import PredictionCommentSection from './components/PredictionCommentSection';
import CommentInvestModal from './components/CommentInvestModal';
const MotionBox = motion(Box);
const PredictionTopicDetail = () => {
const { topicId } = useParams();
const navigate = useNavigate();
const toast = useToast();
const { user } = useAuth();
// 状态
const [topic, setTopic] = useState(null);
const [userAccount, setUserAccount] = useState(null);
const [tradeMode, setTradeMode] = useState('buy');
const [selectedComment, setSelectedComment] = useState(null);
const [commentSectionKey, setCommentSectionKey] = useState(0);
// 模态框
const {
isOpen: isTradeModalOpen,
onOpen: onTradeModalOpen,
onClose: onTradeModalClose,
} = useDisclosure();
const {
isOpen: isInvestModalOpen,
onOpen: onInvestModalOpen,
onClose: onInvestModalClose,
} = useDisclosure();
// 加载话题数据
useEffect(() => {
const loadTopic = async () => {
try {
const response = await getTopicDetail(topicId);
if (response.success) {
setTopic(response.data);
} else {
toast({
title: '话题不存在',
status: 'error',
duration: 3000,
});
navigate('/value-forum');
}
} catch (error) {
console.error('获取话题详情失败:', error);
toast({
title: '加载失败',
description: error.message,
status: 'error',
duration: 3000,
});
navigate('/value-forum');
}
};
const loadAccount = async () => {
if (!user) return;
try {
const response = await getUserAccount();
if (response.success) {
setUserAccount(response.data);
}
} catch (error) {
console.error('获取账户失败:', error);
}
};
loadTopic();
loadAccount();
}, [topicId, user, toast, navigate]);
// 打开交易弹窗
const handleOpenTrade = (mode) => {
if (!user) {
toast({
title: '请先登录',
status: 'warning',
duration: 3000,
});
return;
}
setTradeMode(mode);
onTradeModalOpen();
};
// 交易成功回调
const handleTradeSuccess = async () => {
// 刷新话题数据
try {
const topicResponse = await getTopicDetail(topicId);
if (topicResponse.success) {
setTopic(topicResponse.data);
}
const accountResponse = await getUserAccount();
if (accountResponse.success) {
setUserAccount(accountResponse.data);
}
} catch (error) {
console.error('刷新数据失败:', error);
}
};
// 打开投资弹窗
const handleOpenInvest = (comment) => {
if (!user) {
toast({
title: '请先登录',
status: 'warning',
duration: 3000,
});
return;
}
setSelectedComment(comment);
onInvestModalOpen();
};
// 投资成功回调
const handleInvestSuccess = async () => {
// 刷新账户数据
try {
const accountResponse = await getUserAccount();
if (accountResponse.success) {
setUserAccount(accountResponse.data);
}
// 刷新评论区通过更新key触发重新加载
setCommentSectionKey((prev) => prev + 1);
toast({
title: '投资成功',
description: '评论列表已刷新',
status: 'success',
duration: 2000,
});
} catch (error) {
console.error('刷新数据失败:', error);
}
};
if (!topic) {
return null;
}
// 获取选项数据(从后端扁平结构映射到前端使用的嵌套结构)
const yesData = {
total_shares: topic.yes_total_shares || 0,
current_price: topic.yes_price || 500,
lord_id: topic.yes_lord_id || null,
};
const noData = {
total_shares: topic.no_total_shares || 0,
current_price: topic.no_price || 500,
lord_id: topic.no_lord_id || null,
};
// 计算总份额
const totalShares = yesData.total_shares + noData.total_shares;
// 计算百分比
const yesPercent = totalShares > 0 ? (yesData.total_shares / totalShares) * 100 : 50;
const noPercent = totalShares > 0 ? (noData.total_shares / totalShares) * 100 : 50;
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = date - now;
const days = Math.floor(diff / 86400000);
const hours = Math.floor(diff / 3600000);
if (days > 0) return `${days}天后`;
if (hours > 0) return `${hours}小时后`;
return '即将截止';
};
return (
<Box minH="100vh" bg={forumColors.background.main} pt={{ base: "60px", md: "80px" }} pb={{ base: "6", md: "20" }}>
<Container maxW="container.xl" px={{ base: "3", sm: "4", md: "6" }}>
{/* 头部:返回按钮 */}
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/value-forum')}
mb="6"
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
返回论坛
</Button>
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing="6">
{/* 左侧:主要内容 */}
<Box gridColumn={{ base: '1', lg: '1 / 3' }}>
<VStack spacing="6" align="stretch">
{/* 话题信息卡片 */}
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
overflow="hidden"
>
{/* 头部 */}
<Box
bg={forumColors.gradients.goldSubtle}
px={{ base: "4", md: "6" }}
py={{ base: "3", md: "4" }}
borderBottom="1px solid"
borderColor={forumColors.border.default}
>
<HStack justify="space-between">
<Badge
bg={forumColors.primary[500]}
color="white"
px="3"
py="1"
borderRadius="full"
fontSize="sm"
>
{topic.category}
</Badge>
<HStack spacing="3">
<Icon as={Clock} boxSize="16px" color={forumColors.text.secondary} />
<Text fontSize="sm" color={forumColors.text.secondary}>
{formatTime(topic.deadline)} 截止
</Text>
</HStack>
</HStack>
<Heading
as="h1"
fontSize={{ base: "lg", md: "2xl" }}
fontWeight="bold"
color={forumColors.text.primary}
mt={{ base: "3", md: "4" }}
lineHeight="1.4"
>
{topic.title}
</Heading>
<Text fontSize={{ base: "sm", md: "md" }} color={forumColors.text.secondary} mt={{ base: "2", md: "3" }}>
{topic.description}
</Text>
{/* 作者信息 */}
<HStack mt="4" spacing="3">
<Avatar
size="sm"
name={topic.author_name}
src={topic.author_avatar}
bg={forumColors.gradients.goldPrimary}
/>
<VStack align="start" spacing="0">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
{topic.author_name}
</Text>
<Text fontSize="xs" color={forumColors.text.tertiary}>
发起者
</Text>
</VStack>
</HStack>
</Box>
{/* 市场数据 */}
<Box p={{ base: "4", md: "6" }}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: "4", md: "6" }}>
{/* Yes 方 */}
<Box
bg="linear-gradient(135deg, rgba(72, 187, 120, 0.1) 0%, rgba(72, 187, 120, 0.05) 100%)"
border="2px solid"
borderColor="green.400"
borderRadius="xl"
p={{ base: "4", md: "6" }}
position="relative"
>
{yesData.lord_id && (
<HStack
position="absolute"
top="3"
right="3"
spacing="1"
bg="yellow.400"
px="2"
py="1"
borderRadius="full"
>
<Icon as={Crown} boxSize="12px" color="white" />
<Text fontSize="xs" fontWeight="bold" color="white">
领主
</Text>
</HStack>
)}
<VStack align="start" spacing="4">
<HStack spacing="2">
<Icon as={TrendingUp} boxSize="20px" color="green.500" />
<Text fontSize="lg" fontWeight="700" color="green.600">
看涨 / Yes
</Text>
</HStack>
<VStack align="start" spacing="1">
<Text fontSize="xs" color={forumColors.text.secondary}>
当前价格
</Text>
<HStack spacing="1">
<Text fontSize="3xl" fontWeight="bold" color="green.600">
{Math.round(yesData.current_price)}
</Text>
<Text fontSize="sm" color={forumColors.text.secondary}>
积分/
</Text>
</HStack>
</VStack>
<Divider />
<HStack justify="space-between" w="full">
<Text fontSize="sm" color={forumColors.text.secondary}>
总份额
</Text>
<Text fontSize="md" fontWeight="600" color="green.600">
{yesData.total_shares}
</Text>
</HStack>
<HStack justify="space-between" w="full">
<Text fontSize="sm" color={forumColors.text.secondary}>
市场占比
</Text>
<Text fontSize="md" fontWeight="600" color="green.600">
{yesPercent.toFixed(1)}%
</Text>
</HStack>
</VStack>
</Box>
{/* No 方 */}
<Box
bg="linear-gradient(135deg, rgba(245, 101, 101, 0.1) 0%, rgba(245, 101, 101, 0.05) 100%)"
border="2px solid"
borderColor="red.400"
borderRadius="xl"
p={{ base: "4", md: "6" }}
position="relative"
>
{noData.lord_id && (
<HStack
position="absolute"
top="3"
right="3"
spacing="1"
bg="yellow.400"
px="2"
py="1"
borderRadius="full"
>
<Icon as={Crown} boxSize="12px" color="white" />
<Text fontSize="xs" fontWeight="bold" color="white">
领主
</Text>
</HStack>
)}
<VStack align="start" spacing="4">
<HStack spacing="2">
<Icon as={TrendingDown} boxSize="20px" color="red.500" />
<Text fontSize="lg" fontWeight="700" color="red.600">
看跌 / No
</Text>
</HStack>
<VStack align="start" spacing="1">
<Text fontSize="xs" color={forumColors.text.secondary}>
当前价格
</Text>
<HStack spacing="1">
<Text fontSize="3xl" fontWeight="bold" color="red.600">
{Math.round(noData.current_price)}
</Text>
<Text fontSize="sm" color={forumColors.text.secondary}>
积分/
</Text>
</HStack>
</VStack>
<Divider />
<HStack justify="space-between" w="full">
<Text fontSize="sm" color={forumColors.text.secondary}>
总份额
</Text>
<Text fontSize="md" fontWeight="600" color="red.600">
{noData.total_shares}
</Text>
</HStack>
<HStack justify="space-between" w="full">
<Text fontSize="sm" color={forumColors.text.secondary}>
市场占比
</Text>
<Text fontSize="md" fontWeight="600" color="red.600">
{noPercent.toFixed(1)}%
</Text>
</HStack>
</VStack>
</Box>
</SimpleGrid>
{/* 市场情绪进度条 */}
<Box mt="6">
<Flex justify="space-between" mb="2">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
市场情绪分布
</Text>
<Text fontSize="sm" color={forumColors.text.secondary}>
{yesPercent.toFixed(1)}% vs {noPercent.toFixed(1)}%
</Text>
</Flex>
<Progress
value={yesPercent}
size="lg"
borderRadius="full"
bg="red.200"
sx={{
'& > div': {
bg: 'linear-gradient(90deg, #48BB78 0%, #38A169 100%)',
},
}}
/>
</Box>
</Box>
</MotionBox>
</VStack>
</Box>
{/* 右侧:操作面板 */}
<Box gridColumn={{ base: '1', lg: '3' }}>
<VStack spacing={{ base: "4", md: "6" }} align="stretch" position={{ base: "relative", lg: "sticky" }} top={{ base: "0", lg: "90px" }}>
{/* 奖池信息 */}
<Box
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.gold}
p={{ base: "4", md: "6" }}
>
<VStack spacing="4" align="stretch">
<HStack justify="center" spacing="2">
<Icon as={DollarSign} boxSize="24px" color={forumColors.primary[500]} />
<Text fontSize="sm" fontWeight="600" color={forumColors.text.secondary}>
当前奖池
</Text>
</HStack>
<Text
fontSize={{ base: "3xl", md: "4xl" }}
fontWeight="bold"
color={forumColors.primary[500]}
textAlign="center"
>
{topic.total_pool}
</Text>
<Text fontSize="sm" color={forumColors.text.secondary} textAlign="center">
积分
</Text>
<Divider />
<HStack justify="space-between" fontSize="sm">
<Text color={forumColors.text.secondary}>参与人数</Text>
<HStack spacing="1">
<Icon as={Users} boxSize="14px" color={forumColors.text.primary} />
<Text fontWeight="600" color={forumColors.text.primary}>
{topic.participants_count || 0}
</Text>
</HStack>
</HStack>
<HStack justify="space-between" fontSize="sm">
<Text color={forumColors.text.secondary}>总交易量</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round((topic.yes_total_shares || 0) + (topic.no_total_shares || 0))}
</Text>
</HStack>
</VStack>
</Box>
{/* 交易按钮 */}
{topic.status === 'active' && (
<VStack spacing="3">
<Button
leftIcon={<ShoppingCart size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
size="lg"
w="full"
h={{ base: "12", md: "auto" }}
fontWeight="bold"
fontSize={{ base: "md", md: "lg" }}
onClick={() => handleOpenTrade('buy')}
_hover={{
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
}}
_active={{ transform: 'translateY(0)' }}
>
购买席位
</Button>
<Button
leftIcon={<ArrowLeftRight size={18} />}
variant="outline"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
size="lg"
w="full"
h={{ base: "12", md: "auto" }}
fontWeight="bold"
fontSize={{ base: "md", md: "lg" }}
onClick={() => handleOpenTrade('sell')}
_hover={{
bg: forumColors.background.hover,
borderColor: forumColors.border.gold,
}}
>
卖出席位
</Button>
</VStack>
)}
{/* 用户余额 */}
{user && userAccount && (
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p="4"
border="1px solid"
borderColor={forumColors.border.default}
>
<VStack spacing="2" align="stretch" fontSize="sm">
<HStack justify="space-between">
<Text color={forumColors.text.secondary}>可用余额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{userAccount.balance} 积分
</Text>
</HStack>
<HStack justify="space-between">
<Text color={forumColors.text.secondary}>冻结积分</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{userAccount.frozen} 积分
</Text>
</HStack>
</VStack>
</Box>
)}
</VStack>
</Box>
{/* 评论区 - 占据全宽 */}
<Box gridColumn={{ base: "1", lg: "1 / -1" }}>
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<PredictionCommentSection
key={commentSectionKey}
topicId={topicId}
topic={topic}
onInvest={handleOpenInvest}
/>
</MotionBox>
</Box>
</SimpleGrid>
</Container>
{/* 交易模态框 */}
<TradeModal
isOpen={isTradeModalOpen}
onClose={onTradeModalClose}
topic={topic}
mode={tradeMode}
onTradeSuccess={handleTradeSuccess}
/>
{/* 观点投资模态框 */}
<CommentInvestModal
isOpen={isInvestModalOpen}
onClose={onInvestModalClose}
comment={selectedComment}
topic={topic}
onInvestSuccess={handleInvestSuccess}
/>
</Box>
);
};
export default PredictionTopicDetail;

View File

@@ -0,0 +1,464 @@
/**
* 观点IPO投资弹窗组件
* 用于投资评论观点
*/
import React, { useState, useEffect } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
VStack,
HStack,
Text,
Box,
Icon,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
Flex,
useToast,
Avatar,
Badge,
Divider,
} from '@chakra-ui/react';
import { TrendingUp, DollarSign, AlertCircle, Lightbulb, Crown } from 'lucide-react';
import { motion } from 'framer-motion';
import { forumColors } from '@theme/forumTheme';
import {
investComment,
getUserAccount,
} from '@services/predictionMarketService.api';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const CommentInvestModal = ({ isOpen, onClose, comment, topic, onInvestSuccess }) => {
const toast = useToast();
const { user } = useAuth();
const [shares, setShares] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [userAccount, setUserAccount] = useState(null);
// 异步获取用户账户
useEffect(() => {
const fetchAccount = async () => {
if (!user || !isOpen) return;
try {
const response = await getUserAccount();
if (response.success) {
setUserAccount(response.data);
}
} catch (error) {
console.error('获取账户失败:', error);
}
};
fetchAccount();
}, [user, isOpen]);
// 重置状态
useEffect(() => {
if (isOpen) {
setShares(1);
}
}, [isOpen]);
if (!comment || !userAccount) return null;
// 计算投资成本后端算法基础价格100 + 已有投资额/10
const basePrice = 100;
const priceIncrease = (comment.total_investment || 0) / 10;
const pricePerShare = basePrice + priceIncrease;
const totalCost = Math.round(pricePerShare * shares);
// 预期收益如果预测正确获得1.5倍回报)
const expectedReturn = Math.round(totalCost * 1.5);
const expectedProfit = expectedReturn - totalCost;
// 检查是否可以投资
const canInvest = () => {
// 检查余额
if (userAccount.balance < totalCost) {
return { ok: false, reason: '积分不足' };
}
// 不能投资自己的评论
if (comment.user?.id === user?.id) {
return { ok: false, reason: '不能投资自己的评论' };
}
// 检查是否已结算
if (comment.is_verified) {
return { ok: false, reason: '该评论已结算' };
}
return { ok: true };
};
const investCheck = canInvest();
// 处理投资
const handleInvest = async () => {
try {
setIsSubmitting(true);
const response = await investComment(comment.id, shares);
if (response.success) {
toast({
title: '投资成功!',
description: `投资${totalCost}积分,剩余 ${response.data.new_balance} 积分`,
status: 'success',
duration: 3000,
});
// 刷新账户数据
const accountResponse = await getUserAccount();
if (accountResponse.success) {
setUserAccount(accountResponse.data);
}
// 通知父组件刷新
if (onInvestSuccess) {
onInvestSuccess(response.data);
}
onClose();
}
} catch (error) {
console.error('投资失败:', error);
toast({
title: '投资失败',
description: error.response?.data?.error || error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
// 判断是否是庄主
const isYesLord = comment.user?.id === topic?.yes_lord_id;
const isNoLord = comment.user?.id === topic?.no_lord_id;
const isLord = isYesLord || isNoLord;
return (
<Modal isOpen={isOpen} onClose={onClose} size={{ base: "full", sm: "lg" }} isCentered>
<ModalOverlay backdropFilter="blur(4px)" />
<ModalContent
bg={forumColors.background.card}
borderRadius={{ base: "0", sm: "xl" }}
border="1px solid"
borderColor={forumColors.border.default}
maxH={{ base: "100vh", sm: "90vh" }}
m={{ base: "0", sm: "4" }}
>
<ModalHeader
bg={forumColors.gradients.goldSubtle}
borderTopRadius={{ base: "0", sm: "xl" }}
borderBottom="1px solid"
borderColor={forumColors.border.default}
py={{ base: "4", sm: "3" }}
>
<HStack spacing="2">
<Icon
as={Lightbulb}
boxSize={{ base: "18px", sm: "20px" }}
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.primary} fontSize={{ base: "md", sm: "lg" }}>
投资观点
</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color={forumColors.text.primary} />
<ModalBody py={{ base: "4", sm: "6" }} px={{ base: "4", sm: "6" }}>
<VStack spacing="5" align="stretch">
{/* 评论作者信息 */}
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
border="1px solid"
borderColor={forumColors.border.default}
>
<HStack spacing="3" mb="2">
<Avatar
size="sm"
name={comment.user?.nickname || comment.user?.username}
src={comment.user?.avatar_url}
bg={forumColors.gradients.goldPrimary}
/>
<VStack align="start" spacing="0" flex="1">
<HStack spacing="2">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
{comment.user?.nickname || comment.user?.username || '匿名用户'}
</Text>
{isLord && (
<Badge
bg={isYesLord ? 'green.500' : 'red.500'}
color="white"
fontSize="2xs"
px="2"
py="0.5"
borderRadius="full"
display="flex"
alignItems="center"
gap="1"
>
<Crown size={10} />
{isYesLord ? 'YES庄' : 'NO庄'}
</Badge>
)}
</HStack>
</VStack>
</HStack>
{/* 评论内容 */}
<Text fontSize={{ base: "sm", sm: "md" }} color={forumColors.text.secondary} lineHeight="1.6">
{comment.content}
</Text>
</Box>
{/* 当前投资统计 */}
<Box
bg={forumColors.gradients.goldSubtle}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
>
<VStack spacing="2" align="stretch">
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>已有投资人数</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{comment.investor_count || 0}
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>总投资额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{comment.total_investment || 0} 积分
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>当前价格</Text>
<Text fontWeight="600" color={forumColors.primary[500]}>
{Math.round(pricePerShare)} 积分/
</Text>
</Flex>
</VStack>
</Box>
{/* 投资份额 */}
<Box>
<Flex justify="space-between" mb={{ base: "2", sm: "3" }}>
<Text fontSize={{ base: "sm", sm: "sm" }} fontWeight="600" color={forumColors.text.primary}>
投资份额
</Text>
<Text fontSize={{ base: "sm", sm: "sm" }} color={forumColors.text.secondary}>
{shares}
</Text>
</Flex>
<Slider
value={shares}
onChange={setShares}
min={1}
max={10}
step={1}
focusThumbOnChange={false}
>
<SliderTrack bg={forumColors.background.hover} h={{ base: "2", sm: "1.5" }}>
<SliderFilledTrack bg={forumColors.gradients.goldPrimary} />
</SliderTrack>
<SliderThumb boxSize={{ base: "7", sm: "6" }} bg={forumColors.primary[500]}>
<Box as={Icon} as={DollarSign} boxSize={{ base: "14px", sm: "12px" }} color="white" />
</SliderThumb>
</Slider>
<HStack justify="space-between" mt="2" fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.tertiary}>
<Text>1</Text>
<Text>10 (最大)</Text>
</HStack>
</Box>
<Divider borderColor={forumColors.border.default} />
{/* 费用明细 */}
<Box
bg={forumColors.background.hover}
border="1px solid"
borderColor={forumColors.border.default}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
>
<VStack spacing={{ base: "1.5", sm: "2" }} align="stretch">
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>单价</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round(pricePerShare)} 积分/
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>份额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{shares}
</Text>
</Flex>
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt={{ base: "1.5", sm: "2" }} mt="1">
<Flex justify="space-between">
<Text fontWeight="bold" color={forumColors.text.primary} fontSize={{ base: "sm", sm: "md" }}>
投资总额
</Text>
<HStack spacing="1">
<Icon as={DollarSign} boxSize={{ base: "16px", sm: "20px" }} color={forumColors.primary[500]} />
<Text fontSize={{ base: "xl", sm: "2xl" }} fontWeight="bold" color={forumColors.primary[500]}>
{totalCost}
</Text>
<Text fontSize={{ base: "xs", sm: "sm" }} color={forumColors.text.secondary}>
积分
</Text>
</HStack>
</Flex>
</Box>
{/* 预期收益 */}
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt={{ base: "1.5", sm: "2" }}>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color="green.600" fontWeight="600">
<Icon as={TrendingUp} size={14} display="inline" mr="1" />
预测正确收益1.5
</Text>
<Text fontWeight="600" color="green.600">
+{expectedProfit} 积分
</Text>
</Flex>
<Text fontSize="2xs" color={forumColors.text.muted} mt="1">
预测正确将获得 {expectedReturn} 积分含本金
</Text>
</Box>
</VStack>
</Box>
{/* 余额提示 */}
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p={{ base: "2.5", sm: "3" }}
>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>你的余额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{userAccount.balance} 积分
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }} mt="1">
<Text color={forumColors.text.secondary}>投资后</Text>
<Text
fontWeight="600"
color={
userAccount.balance >= totalCost
? forumColors.success[500]
: forumColors.error[500]
}
>
{userAccount.balance - totalCost} 积分
</Text>
</Flex>
</Box>
{/* 警告提示 */}
{!investCheck.ok && (
<Box
bg="red.50"
border="1px solid"
borderColor="red.200"
borderRadius="lg"
p={{ base: "2.5", sm: "3" }}
>
<HStack spacing="2">
<Icon as={AlertCircle} boxSize={{ base: "14px", sm: "16px" }} color="red.500" />
<Text fontSize={{ base: "xs", sm: "sm" }} color="red.600" fontWeight="600">
{investCheck.reason}
</Text>
</HStack>
</Box>
)}
{/* 风险提示 */}
<Box
bg="orange.50"
border="1px solid"
borderColor="orange.200"
borderRadius="lg"
p={{ base: "2.5", sm: "3" }}
>
<HStack spacing="2" mb="1">
<Icon as={AlertCircle} boxSize="14px" color="orange.500" />
<Text fontSize="xs" color="orange.700" fontWeight="600">
投资风险提示
</Text>
</HStack>
<Text fontSize="2xs" color="orange.600" lineHeight="1.5">
观点预测存在不确定性预测错误将损失全部投资请谨慎评估后再投资
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter
borderTop="1px solid"
borderColor={forumColors.border.default}
py={{ base: "3", sm: "4" }}
px={{ base: "4", sm: "6" }}
>
<HStack spacing={{ base: "2", sm: "3" }} w="full" justify="flex-end">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
h={{ base: "10", sm: "auto" }}
fontSize={{ base: "sm", sm: "md" }}
>
取消
</Button>
<Button
bg={forumColors.gradients.goldPrimary}
color="white"
fontWeight="bold"
onClick={handleInvest}
isLoading={isSubmitting}
loadingText="投资中..."
isDisabled={!investCheck.ok}
_hover={{
opacity: 0.9,
transform: 'translateY(-2px)',
}}
_active={{ transform: 'translateY(0)' }}
h={{ base: "11", sm: "auto" }}
fontSize={{ base: "sm", sm: "md" }}
px={{ base: "6", sm: "4" }}
>
投资 {shares}
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default CommentInvestModal;

View File

@@ -70,22 +70,146 @@ const CreatePostModal = ({ isOpen, onClose, onPostCreated }) => {
return Object.keys(newErrors).length === 0;
};
// 处理图片上传
const handleImageUpload = (e) => {
const files = Array.from(e.target.files);
// 压缩图片
const compressImage = (file) => {
return new Promise((resolve, reject) => {
// 检查文件类型
if (!file.type.startsWith('image/')) {
reject(new Error('请选择图片文件'));
return;
}
// 检查文件大小10MB 限制,给压缩留空间)
if (file.size > 10 * 1024 * 1024) {
reject(new Error('图片大小不能超过 10MB'));
return;
}
files.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({
...prev,
images: [...prev.images, reader.result],
}));
reader.onload = function(e) {
const img = document.createElement('img');
img.onload = function() {
try {
// 创建 canvas 进行压缩
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// 如果图片尺寸过大,等比缩放到最大 1920px
const maxDimension = 1920;
if (width > maxDimension || height > maxDimension) {
if (width > height) {
height = Math.round((height * maxDimension) / width);
width = maxDimension;
} else {
width = Math.round((width * maxDimension) / height);
height = maxDimension;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// 转换为 Data URLJPEG 格式,质量 0.8
try {
const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.8);
// 计算压缩率
const originalSize = file.size;
const compressedSize = Math.round((compressedDataUrl.length * 3) / 4); // Base64 解码后的大小
console.log(
`图片压缩: ${(originalSize / 1024).toFixed(2)}KB -> ${(compressedSize / 1024).toFixed(2)}KB`
);
resolve(compressedDataUrl);
} catch (error) {
reject(new Error('图片压缩失败'));
}
} catch (error) {
reject(new Error(`图片处理失败: ${error.message}`));
}
};
img.onerror = function() {
reject(new Error('图片加载失败'));
};
img.src = e.target.result;
};
reader.onerror = function() {
reject(new Error('文件读取失败'));
};
reader.readAsDataURL(file);
});
};
// 处理图片上传
const handleImageUpload = async (e) => {
const files = Array.from(e.target.files);
// 清空 input 以支持重复上传同一文件
e.target.value = '';
if (files.length === 0) return;
// 检查总数量限制
if (formData.images.length + files.length > 9) {
toast({
title: '图片数量超限',
description: '最多只能上传 9 张图片',
status: 'warning',
duration: 3000,
});
return;
}
// 逐个处理图片,而不是使用 Promise.all
const compressedImages = [];
let hasError = false;
for (let i = 0; i < files.length; i++) {
try {
const compressed = await compressImage(files[i]);
compressedImages.push(compressed);
} catch (error) {
console.error('图片压缩失败:', error);
hasError = true;
toast({
title: '图片处理失败',
description: error.message || `${i + 1} 张图片处理失败`,
status: 'error',
duration: 3000,
});
break; // 遇到错误就停止
}
}
// 如果有成功压缩的图片,添加到表单
if (compressedImages.length > 0) {
setFormData((prev) => ({
...prev,
images: [...prev.images, ...compressedImages],
}));
if (!hasError) {
toast({
title: '上传成功',
description: `成功添加 ${compressedImages.length} 张图片`,
status: 'success',
duration: 2000,
});
}
}
};
// 移除图片
const removeImage = (index) => {
setFormData((prev) => ({

View File

@@ -0,0 +1,401 @@
/**
* 创建预测话题模态框
* 用户可以发起新的预测市场话题
*/
import React, { useState, useEffect } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
VStack,
FormControl,
FormLabel,
Input,
Textarea,
Select,
HStack,
Text,
Box,
Icon,
Alert,
AlertIcon,
useToast,
} from '@chakra-ui/react';
import { Zap, Calendar, DollarSign } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import { createTopic, getUserAccount } from '@services/predictionMarketService.api';
import { CREDIT_CONFIG } from '@services/creditSystemService';
import { useAuth } from '@contexts/AuthContext';
const CreatePredictionModal = ({ isOpen, onClose, onTopicCreated }) => {
const toast = useToast();
const { user } = useAuth();
// 表单状态
const [formData, setFormData] = useState({
title: '',
description: '',
category: 'stock',
deadline_days: 7,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [userAccount, setUserAccount] = useState(null);
// 异步获取用户余额
useEffect(() => {
const fetchAccount = async () => {
if (!user || !isOpen) return;
try {
const response = await getUserAccount();
if (response.success) {
setUserAccount(response.data);
}
} catch (error) {
console.error('获取账户失败:', error);
}
};
fetchAccount();
}, [user, isOpen]);
// 处理表单变化
const handleChange = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// 提交表单
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// 验证
if (!formData.title.trim()) {
toast({
title: '请填写话题标题',
status: 'warning',
duration: 3000,
});
return;
}
if (!formData.description.trim()) {
toast({
title: '请填写话题描述',
status: 'warning',
duration: 3000,
});
return;
}
// 检查余额
if (!userAccount || userAccount.balance < CREDIT_CONFIG.CREATE_TOPIC_COST) {
toast({
title: '积分不足',
description: `创建话题需要${CREDIT_CONFIG.CREATE_TOPIC_COST}积分`,
status: 'error',
duration: 3000,
});
return;
}
// 计算截止时间
const deadline = new Date();
deadline.setDate(deadline.getDate() + parseInt(formData.deadline_days));
// 调用 API 创建话题
const response = await createTopic({
title: formData.title,
description: formData.description,
category: formData.category,
deadline: deadline.toISOString(),
});
if (response.success) {
toast({
title: '创建成功!',
description: `话题已发布,剩余 ${response.data.new_balance} 积分`,
status: 'success',
duration: 3000,
});
// 重置表单
setFormData({
title: '',
description: '',
category: 'stock',
deadline_days: 7,
});
// 通知父组件
if (onTopicCreated) {
onTopicCreated(response.data);
}
onClose();
// 刷新账户数据
const accountResponse = await getUserAccount();
if (accountResponse.success) {
setUserAccount(accountResponse.data);
}
}
} catch (error) {
console.error('创建话题失败:', error);
toast({
title: '创建失败',
description: error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay backdropFilter="blur(4px)" />
<ModalContent
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
>
<ModalHeader
bg={forumColors.gradients.goldSubtle}
borderTopRadius="xl"
borderBottom="1px solid"
borderColor={forumColors.border.default}
>
<HStack spacing="2">
<Icon as={Zap} boxSize="20px" color={forumColors.primary[500]} />
<Text color={forumColors.text.primary}>发起预测话题</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color={forumColors.text.primary} />
<ModalBody py="6">
<VStack spacing="5" align="stretch">
{/* 提示信息 */}
<Alert
status="info"
bg={forumColors.background.hover}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
>
<AlertIcon color={forumColors.primary[500]} />
<VStack align="start" spacing="1" flex="1">
<Text fontSize="sm" color={forumColors.text.primary} fontWeight="600">
创建预测话题
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
创建费用{CREDIT_CONFIG.CREATE_TOPIC_COST}积分进入奖池
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
作者不能参与自己发起的话题
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
截止后由作者提交结果进行结算
</Text>
</VStack>
</Alert>
{/* 话题标题 */}
<FormControl isRequired>
<FormLabel fontSize="sm" color={forumColors.text.primary}>
话题标题
</FormLabel>
<Input
placeholder="例如:贵州茅台下周会涨吗?"
value={formData.title}
onChange={(e) => handleChange('title', e.target.value)}
bg={forumColors.background.main}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
</FormControl>
{/* 话题描述 */}
<FormControl isRequired>
<FormLabel fontSize="sm" color={forumColors.text.primary}>
话题描述
</FormLabel>
<Textarea
placeholder="详细描述预测的内容、判断标准、数据来源等..."
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
rows={4}
bg={forumColors.background.main}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
</FormControl>
{/* 分类 */}
<FormControl>
<FormLabel fontSize="sm" color={forumColors.text.primary}>
分类
</FormLabel>
<Select
value={formData.category}
onChange={(e) => handleChange('category', e.target.value)}
bg={forumColors.background.main}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
>
<option value="stock">股票行情</option>
<option value="index">指数走势</option>
<option value="concept">概念板块</option>
<option value="policy">政策影响</option>
<option value="event">事件预测</option>
<option value="other">其他</option>
</Select>
</FormControl>
{/* 截止时间 */}
<FormControl>
<FormLabel fontSize="sm" color={forumColors.text.primary}>
<HStack spacing="2">
<Icon as={Calendar} boxSize="16px" />
<Text>交易截止时间</Text>
</HStack>
</FormLabel>
<Select
value={formData.deadline_days}
onChange={(e) => handleChange('deadline_days', e.target.value)}
bg={forumColors.background.main}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
>
<option value="1">1天后</option>
<option value="3">3天后</option>
<option value="7">7天后推荐</option>
<option value="14">14天后</option>
<option value="30">30天后</option>
</Select>
<Text fontSize="xs" color={forumColors.text.tertiary} mt="2">
截止后次日可提交结果进行结算
</Text>
</FormControl>
{/* 费用说明 */}
<Box
bg={forumColors.gradients.goldSubtle}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="lg"
p="4"
>
<HStack justify="space-between">
<VStack align="start" spacing="1">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
创建费用
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
将进入奖池奖励给获胜者
</Text>
</VStack>
<HStack spacing="1">
<Icon as={DollarSign} boxSize="20px" color={forumColors.primary[500]} />
<Text fontSize="2xl" fontWeight="bold" color={forumColors.primary[500]}>
{CREDIT_CONFIG.CREATE_TOPIC_COST}
</Text>
<Text fontSize="sm" color={forumColors.text.secondary}>
积分
</Text>
</HStack>
</HStack>
<Box mt="3" pt="3" borderTop="1px solid" borderColor={forumColors.border.default}>
<HStack justify="space-between" fontSize="sm">
<Text color={forumColors.text.secondary}>你的余额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{userAccount?.balance || 0} 积分
</Text>
</HStack>
<HStack justify="space-between" fontSize="sm" mt="1">
<Text color={forumColors.text.secondary}>创建后</Text>
<Text
fontWeight="600"
color={
(userAccount?.balance || 0) >= CREDIT_CONFIG.CREATE_TOPIC_COST
? forumColors.success[500]
: forumColors.error[500]
}
>
{(userAccount?.balance || 0) - CREDIT_CONFIG.CREATE_TOPIC_COST} 积分
</Text>
</HStack>
</Box>
</Box>
</VStack>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor={forumColors.border.default}>
<HStack spacing="3">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
取消
</Button>
<Button
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
fontWeight="bold"
onClick={handleSubmit}
isLoading={isSubmitting}
loadingText="创建中..."
isDisabled={(userAccount?.balance || 0) < CREDIT_CONFIG.CREATE_TOPIC_COST}
_hover={{
opacity: 0.9,
transform: 'translateY(-2px)',
}}
_active={{ transform: 'translateY(0)' }}
>
发布话题
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default CreatePredictionModal;

View File

@@ -0,0 +1,475 @@
/**
* 预测话题评论区组件
* 支持发布评论、嵌套回复、点赞、庄主标识、观点IPO投资
*/
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Avatar,
Textarea,
Button,
Flex,
IconButton,
Divider,
useToast,
Badge,
Tooltip,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, MessageCircle, Send, TrendingUp, Crown, Pin } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import {
createComment,
getComments,
likeComment,
} from '@services/predictionMarketService.api';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const CommentItem = ({ comment, topicId, topic, onReply, onInvest }) => {
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(comment.likes_count || 0);
const [showReply, setShowReply] = useState(false);
// 处理点赞
const handleLike = async () => {
try {
const response = await likeComment(comment.id);
if (response.success) {
setLikes(response.likes_count);
setIsLiked(response.action === 'like');
}
} catch (error) {
console.error('点赞失败:', error);
}
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
});
};
// 判断是否是庄主
const isYesLord = comment.user?.id === topic?.yes_lord_id;
const isNoLord = comment.user?.id === topic?.no_lord_id;
const isLord = isYesLord || isNoLord;
return (
<MotionBox
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex gap={{ base: "2", sm: "3" }} py={{ base: "3", sm: "4" }}>
{/* 头像 */}
<Avatar
size={{ base: "sm", sm: "md" }}
name={comment.user?.nickname || comment.user?.username}
src={comment.user?.avatar_url}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
{/* 评论内容 */}
<VStack align="stretch" flex="1" spacing={{ base: "1.5", sm: "2" }}>
{/* 用户名和时间 */}
<HStack justify="space-between" flexWrap="wrap">
<HStack spacing="2">
<Text fontSize={{ base: "sm", sm: "md" }} fontWeight="600" color={forumColors.text.primary}>
{comment.user?.nickname || comment.user?.username || '匿名用户'}
</Text>
{/* 庄主标识 */}
{isLord && (
<Tooltip label={isYesLord ? 'YES方庄主' : 'NO方庄主'} placement="top">
<Badge
bg={isYesLord ? 'green.500' : 'red.500'}
color="white"
fontSize="2xs"
px="2"
py="0.5"
borderRadius="full"
display="flex"
alignItems="center"
gap="1"
>
<Crown size={10} />
{isYesLord ? 'YES庄' : 'NO庄'}
</Badge>
</Tooltip>
)}
{/* 置顶标识 */}
{comment.is_pinned && (
<Badge
bg={forumColors.primary[500]}
color="white"
fontSize="2xs"
px="2"
py="0.5"
borderRadius="full"
display="flex"
alignItems="center"
gap="1"
>
<Pin size={10} />
置顶
</Badge>
)}
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.muted}>
{formatTime(comment.created_at)}
</Text>
</HStack>
</HStack>
{/* 评论正文 */}
<Text fontSize={{ base: "sm", sm: "md" }} color={forumColors.text.secondary} lineHeight="1.6">
{comment.content}
</Text>
{/* 观点IPO投资统计 */}
{comment.total_investment > 0 && (
<Box
bg={forumColors.gradients.goldSubtle}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="md"
px="3"
py="2"
mt="1"
>
<HStack spacing="4" fontSize="xs">
<HStack spacing="1">
<TrendingUp size={12} color={forumColors.primary[500]} />
<Text color={forumColors.text.secondary}>
{comment.investor_count || 0}人投资
</Text>
</HStack>
<Text color={forumColors.text.secondary}>
总投资<Text as="span" fontWeight="600" color={forumColors.primary[500]}>
{comment.total_investment}
</Text>
</Text>
{comment.is_verified && (
<Badge
colorScheme={comment.verification_result === 'correct' ? 'green' : 'red'}
fontSize="2xs"
>
{comment.verification_result === 'correct' ? '✓ 预测正确' : '✗ 预测错误'}
</Badge>
)}
</HStack>
</Box>
)}
{/* 操作按钮 */}
<HStack spacing={{ base: "3", sm: "4" }} fontSize="xs" color={forumColors.text.tertiary} flexWrap="wrap">
<HStack
spacing="1"
cursor="pointer"
onClick={handleLike}
_hover={{ color: forumColors.primary[500] }}
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
>
<Heart size={14} fill={isLiked ? 'currentColor' : 'none'} />
<Text>{likes > 0 ? likes : '点赞'}</Text>
</HStack>
<HStack
spacing="1"
cursor="pointer"
onClick={() => setShowReply(!showReply)}
_hover={{ color: forumColors.primary[500] }}
>
<MessageCircle size={14} />
<Text>回复</Text>
</HStack>
{/* 投资观点按钮 */}
{!comment.is_verified && onInvest && (
<Button
size="xs"
variant="ghost"
leftIcon={<TrendingUp size={14} />}
color={forumColors.primary[500]}
_hover={{ bg: forumColors.gradients.goldSubtle }}
onClick={() => onInvest(comment)}
>
投资观点
</Button>
)}
</HStack>
{/* 回复输入框 */}
{showReply && (
<MotionBox
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
mt="2"
>
<ReplyInput
topicId={topicId}
parentId={comment.id}
placeholder={`回复 @${comment.user?.nickname || comment.user?.username || '匿名用户'}`}
onSubmit={() => {
setShowReply(false);
if (onReply) onReply();
}}
/>
</MotionBox>
)}
{/* 回复列表 */}
{comment.replies && comment.replies.length > 0 && (
<VStack
align="stretch"
spacing="2"
pl={{ base: "3", sm: "4" }}
mt="2"
borderLeft="2px solid"
borderColor={forumColors.border.default}
>
{comment.replies.map((reply) => (
<Box key={reply.id}>
<HStack spacing="2" mb="1">
<Avatar
size="xs"
name={reply.user?.nickname || reply.user?.username}
src={reply.user?.avatar_url}
/>
<Text fontSize="xs" fontWeight="600" color={forumColors.text.primary}>
{reply.user?.nickname || reply.user?.username || '匿名用户'}
</Text>
<Text fontSize="2xs" color={forumColors.text.muted}>
{formatTime(reply.created_at)}
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.secondary} pl="6">
{reply.content}
</Text>
</Box>
))}
</VStack>
)}
</VStack>
</Flex>
</MotionBox>
);
};
const ReplyInput = ({ topicId, parentId = null, placeholder, onSubmit }) => {
const toast = useToast();
const { user } = useAuth();
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!content.trim()) {
toast({
title: '请输入评论内容',
status: 'warning',
duration: 2000,
});
return;
}
setIsSubmitting(true);
try {
const response = await createComment(topicId, {
content: content.trim(),
parent_id: parentId,
});
if (response.success) {
toast({
title: '评论成功',
status: 'success',
duration: 2000,
});
setContent('');
if (onSubmit) onSubmit();
}
} catch (error) {
console.error('评论失败:', error);
toast({
title: '评论失败',
description: error.response?.data?.error || error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Flex gap="2" align="end">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder || '写下你的评论...'}
size="sm"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
minH={{ base: "70px", sm: "80px" }}
resize="vertical"
fontSize={{ base: "sm", sm: "md" }}
/>
<IconButton
icon={<Send size={18} />}
onClick={handleSubmit}
isLoading={isSubmitting}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
size="sm"
h={{ base: "9", sm: "10" }}
/>
</Flex>
);
};
const PredictionCommentSection = ({ topicId, topic, onInvest }) => {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
// 加载评论
const loadComments = async (pageNum = 1) => {
try {
setLoading(true);
const response = await getComments(topicId, { page: pageNum, per_page: 20 });
if (response.success) {
if (pageNum === 1) {
setComments(response.data);
} else {
setComments((prev) => [...prev, ...response.data]);
}
setTotal(response.pagination?.total || response.data.length);
setHasMore(response.pagination?.has_next || false);
setPage(pageNum);
}
} catch (error) {
console.error('加载评论失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComments();
}, [topicId]);
return (
<Box
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
p={{ base: "4", sm: "6" }}
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb={{ base: "4", sm: "6" }}>
<HStack spacing="2">
<MessageCircle size={20} color={forumColors.primary[500]} />
<Text fontSize={{ base: "md", sm: "lg" }} fontWeight="bold" color={forumColors.text.primary}>
评论
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.tertiary}>
{total}
</Text>
</Flex>
{/* 发表评论 */}
<Box mb={{ base: "4", sm: "6" }}>
<ReplyInput topicId={topicId} onSubmit={() => loadComments(1)} />
</Box>
<Divider borderColor={forumColors.border.default} mb="4" />
{/* 评论列表 */}
{loading && page === 1 ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
加载中...
</Text>
) : comments.length === 0 ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
暂无评论快来抢沙发吧
</Text>
) : (
<>
<VStack align="stretch" spacing="0" divider={<Divider borderColor={forumColors.border.default} />}>
<AnimatePresence>
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
topicId={topicId}
topic={topic}
onReply={() => loadComments(1)}
onInvest={onInvest}
/>
))}
</AnimatePresence>
</VStack>
{/* 加载更多 */}
{hasMore && (
<Flex justify="center" mt="6">
<Button
variant="ghost"
onClick={() => loadComments(page + 1)}
isLoading={loading}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
加载更多
</Button>
</Flex>
)}
</>
)}
</Box>
);
};
export default PredictionCommentSection;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,327 @@
/**
* 预测话题卡片组件
* 展示预测市场的话题概览
*/
import React from 'react';
import {
Box,
Text,
HStack,
VStack,
Badge,
Progress,
Flex,
Avatar,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import {
TrendingUp,
TrendingDown,
Crown,
Users,
Clock,
DollarSign,
Zap,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { forumColors } from '@theme/forumTheme';
const MotionBox = motion(Box);
const PredictionTopicCard = ({ topic }) => {
const navigate = useNavigate();
// 处理卡片点击
const handleCardClick = () => {
navigate(`/value-forum/prediction/${topic.id}`);
};
// 格式化数字
const formatNumber = (num) => {
if (num >= 10000) return `${(num / 10000).toFixed(1)}`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num;
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = date - now;
const days = Math.floor(diff / 86400000);
const hours = Math.floor(diff / 3600000);
if (days > 0) return `${days}天后`;
if (hours > 0) return `${hours}小时后`;
return '即将截止';
};
// 获取选项数据
const yesData = topic.positions?.yes || { total_shares: 0, current_price: 500, lord_id: null };
const noData = topic.positions?.no || { total_shares: 0, current_price: 500, lord_id: null };
// 计算总份额
const totalShares = yesData.total_shares + noData.total_shares;
// 计算百分比
const yesPercent = totalShares > 0 ? (yesData.total_shares / totalShares) * 100 : 50;
const noPercent = totalShares > 0 ? (noData.total_shares / totalShares) * 100 : 50;
// 状态颜色
const statusColorMap = {
active: forumColors.success[500],
trading_closed: forumColors.warning[500],
settled: forumColors.text.secondary,
};
const statusLabelMap = {
active: '交易中',
trading_closed: '已截止',
settled: '已结算',
};
return (
<MotionBox
bg={forumColors.background.card}
borderRadius="xl"
overflow="hidden"
border="2px solid"
borderColor={forumColors.border.default}
cursor="pointer"
onClick={handleCardClick}
whileHover={{ y: -8, scale: 1.02 }}
transition={{ duration: 0.3 }}
_hover={{
borderColor: forumColors.border.gold,
boxShadow: forumColors.shadows.gold,
}}
>
{/* 头部:状态标识 */}
<Box
bg={forumColors.gradients.goldSubtle}
px="4"
py="2"
borderBottom="1px solid"
borderColor={forumColors.border.default}
>
<Flex justify="space-between" align="center">
<HStack spacing="2">
<Icon as={Zap} boxSize="16px" color={forumColors.primary[500]} />
<Text fontSize="xs" fontWeight="bold" color={forumColors.primary[500]}>
预测市场
</Text>
</HStack>
<Badge
bg={statusColorMap[topic.status]}
color="white"
px="3"
py="1"
borderRadius="full"
fontSize="xs"
fontWeight="bold"
>
{statusLabelMap[topic.status]}
</Badge>
</Flex>
</Box>
{/* 内容区域 */}
<VStack align="stretch" p="5" spacing="4">
{/* 话题标题 */}
<Text
fontSize="lg"
fontWeight="700"
color={forumColors.text.primary}
noOfLines={2}
lineHeight="1.4"
>
{topic.title}
</Text>
{/* 描述 */}
{topic.description && (
<Text
fontSize="sm"
color={forumColors.text.secondary}
noOfLines={2}
lineHeight="1.6"
>
{topic.description}
</Text>
)}
{/* 双向价格卡片 */}
<HStack spacing="3" w="full">
{/* Yes 方 */}
<Box
flex="1"
bg="linear-gradient(135deg, rgba(72, 187, 120, 0.1) 0%, rgba(72, 187, 120, 0.05) 100%)"
border="2px solid"
borderColor="green.400"
borderRadius="lg"
p="3"
position="relative"
overflow="hidden"
>
{/* 领主徽章 */}
{yesData.lord_id && (
<Icon
as={Crown}
position="absolute"
top="2"
right="2"
boxSize="16px"
color="yellow.400"
/>
)}
<VStack spacing="1" align="start">
<HStack spacing="1">
<Icon as={TrendingUp} boxSize="14px" color="green.500" />
<Text fontSize="xs" fontWeight="600" color="green.600">
看涨 / Yes
</Text>
</HStack>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{Math.round(yesData.current_price)}
<Text as="span" fontSize="xs" ml="1">
积分
</Text>
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
{yesData.total_shares} · {yesPercent.toFixed(0)}%
</Text>
</VStack>
</Box>
{/* No 方 */}
<Box
flex="1"
bg="linear-gradient(135deg, rgba(245, 101, 101, 0.1) 0%, rgba(245, 101, 101, 0.05) 100%)"
border="2px solid"
borderColor="red.400"
borderRadius="lg"
p="3"
position="relative"
overflow="hidden"
>
{/* 领主徽章 */}
{noData.lord_id && (
<Icon
as={Crown}
position="absolute"
top="2"
right="2"
boxSize="16px"
color="yellow.400"
/>
)}
<VStack spacing="1" align="start">
<HStack spacing="1">
<Icon as={TrendingDown} boxSize="14px" color="red.500" />
<Text fontSize="xs" fontWeight="600" color="red.600">
看跌 / No
</Text>
</HStack>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{Math.round(noData.current_price)}
<Text as="span" fontSize="xs" ml="1">
积分
</Text>
</Text>
<Text fontSize="xs" color={forumColors.text.secondary}>
{noData.total_shares} · {noPercent.toFixed(0)}%
</Text>
</VStack>
</Box>
</HStack>
{/* 市场情绪进度条 */}
<Box>
<Flex justify="space-between" mb="1">
<Text fontSize="xs" color={forumColors.text.tertiary}>
市场情绪
</Text>
<Text fontSize="xs" color={forumColors.text.tertiary}>
{yesPercent.toFixed(0)}% vs {noPercent.toFixed(0)}%
</Text>
</Flex>
<Progress
value={yesPercent}
size="sm"
borderRadius="full"
bg="red.100"
sx={{
'& > div': {
bg: 'linear-gradient(90deg, #48BB78 0%, #38A169 100%)',
},
}}
/>
</Box>
{/* 奖池和数据 */}
<HStack spacing="4" fontSize="sm" color={forumColors.text.secondary}>
<HStack spacing="1">
<Icon as={DollarSign} boxSize="16px" color={forumColors.primary[500]} />
<Text fontWeight="600" color={forumColors.primary[500]}>
{formatNumber(topic.total_pool)}
</Text>
<Text fontSize="xs">奖池</Text>
</HStack>
<HStack spacing="1">
<Icon as={Users} boxSize="16px" />
<Text>{topic.stats?.unique_traders?.size || 0}</Text>
</HStack>
<HStack spacing="1">
<Icon as={Clock} boxSize="16px" />
<Text>{formatTime(topic.deadline)}</Text>
</HStack>
</HStack>
{/* 底部:作者信息 */}
<Flex justify="space-between" align="center" pt="2" borderTop="1px solid" borderColor={forumColors.border.default}>
<HStack spacing="2">
<Avatar
size="xs"
name={topic.author_name}
src={topic.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
<Text fontSize="xs" color={forumColors.text.tertiary}>
{topic.author_name}
</Text>
</HStack>
{/* 分类标签 */}
{topic.category && (
<Badge
bg={forumColors.background.hover}
color={forumColors.text.primary}
px="2"
py="1"
borderRadius="md"
fontSize="xs"
>
{topic.category}
</Badge>
)}
</Flex>
</VStack>
</MotionBox>
);
};
export default PredictionTopicCard;

View File

@@ -0,0 +1,547 @@
/**
* 交易模态框组件
* 用于买入/卖出预测市场席位
*/
import React, { useState, useEffect } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
VStack,
HStack,
Text,
Box,
Icon,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
RadioGroup,
Radio,
Stack,
Flex,
useToast,
Badge,
} from '@chakra-ui/react';
import { TrendingUp, TrendingDown, DollarSign, AlertCircle, Zap } from 'lucide-react';
import { motion } from 'framer-motion';
import { forumColors } from '@theme/forumTheme';
import {
buyShares,
getUserAccount,
calculateBuyCost,
calculateTax,
MARKET_CONFIG,
} from '@services/predictionMarketService.api';
import { CREDIT_CONFIG } from '@services/creditSystemService';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const TradeModal = ({ isOpen, onClose, topic, mode = 'buy', onTradeSuccess }) => {
const toast = useToast();
const { user } = useAuth();
// 状态
const [selectedOption, setSelectedOption] = useState('yes');
const [shares, setShares] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [userAccount, setUserAccount] = useState(null);
// 异步获取用户账户
useEffect(() => {
const fetchAccount = async () => {
if (!user || !isOpen) return;
try {
const response = await getUserAccount();
if (response.success) {
setUserAccount(response.data);
}
} catch (error) {
console.error('获取账户失败:', error);
}
};
fetchAccount();
}, [user, isOpen]);
// 重置状态
useEffect(() => {
if (isOpen) {
setSelectedOption('yes');
setShares(1);
}
}, [isOpen]);
if (!topic || !userAccount) return null;
// 构建市场数据(兼容后端字段)
const positions = {
yes: {
total_shares: topic.yes_total_shares || 0,
current_price: topic.yes_price || 500,
},
no: {
total_shares: topic.no_total_shares || 0,
current_price: topic.no_price || 500,
},
};
// 获取市场数据
const selectedSide = positions[selectedOption];
const otherOption = selectedOption === 'yes' ? 'no' : 'yes';
const otherSide = positions[otherOption];
// 计算交易数据
let cost = 0;
let tax = 0;
let totalCost = 0;
let avgPrice = 0;
if (mode === 'buy') {
const costData = calculateBuyCost(
selectedOption === 'yes' ? selectedSide.total_shares : otherSide.total_shares,
selectedOption === 'yes' ? otherSide.total_shares : selectedSide.total_shares,
shares
);
cost = costData.amount;
tax = costData.tax;
totalCost = costData.total;
avgPrice = costData.avgPrice;
} else {
// 卖出功能暂未实现,使用简化计算
const currentPrice = selectedSide.current_price || MARKET_CONFIG.BASE_PRICE;
cost = currentPrice * shares;
tax = calculateTax(cost);
totalCost = cost - tax;
avgPrice = currentPrice;
}
// 获取用户在该方向的持仓
const userPosition = userAccount.active_positions?.find(
(p) => p.topic_id === topic.id && p.option_id === selectedOption
);
const maxShares = mode === 'buy' ? 10 : userPosition?.shares || 0;
// 检查是否可以交易
const canTrade = () => {
if (mode === 'buy') {
// 检查余额
if (userAccount.balance < totalCost) {
return { ok: false, reason: '积分不足' };
}
// 检查单次上限
if (totalCost > CREDIT_CONFIG.MAX_SINGLE_BET) {
return { ok: false, reason: `单次购买上限${CREDIT_CONFIG.MAX_SINGLE_BET}积分` };
}
return { ok: true };
} else {
// 检查持仓
if (!userPosition || userPosition.shares < shares) {
return { ok: false, reason: '持仓不足' };
}
return { ok: true };
}
};
const tradeCheck = canTrade();
// 处理交易
const handleTrade = async () => {
try {
setIsSubmitting(true);
if (mode === 'buy') {
// 调用买入 API
const response = await buyShares({
topic_id: topic.id,
direction: selectedOption,
shares,
});
if (response.success) {
toast({
title: '购买成功!',
description: `花费${totalCost}积分,剩余 ${response.data.new_balance} 积分`,
status: 'success',
duration: 3000,
});
// 刷新账户数据
const accountResponse = await getUserAccount();
if (accountResponse.success) {
setUserAccount(accountResponse.data);
}
// 通知父组件刷新
if (onTradeSuccess) {
onTradeSuccess(response.data);
}
onClose();
}
} else {
// 卖出功能暂未实现
toast({
title: '功能暂未开放',
description: '卖出功能正在开发中,敬请期待',
status: 'warning',
duration: 3000,
});
}
} catch (error) {
console.error('交易失败:', error);
toast({
title: '交易失败',
description: error.response?.data?.message || error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size={{ base: "full", sm: "lg" }} isCentered>
<ModalOverlay backdropFilter="blur(4px)" />
<ModalContent
bg={forumColors.background.card}
borderRadius={{ base: "0", sm: "xl" }}
border="1px solid"
borderColor={forumColors.border.default}
maxH={{ base: "100vh", sm: "90vh" }}
m={{ base: "0", sm: "4" }}
>
<ModalHeader
bg={forumColors.gradients.goldSubtle}
borderTopRadius={{ base: "0", sm: "xl" }}
borderBottom="1px solid"
borderColor={forumColors.border.default}
py={{ base: "4", sm: "3" }}
>
<HStack spacing="2">
<Icon
as={mode === 'buy' ? Zap : DollarSign}
boxSize={{ base: "18px", sm: "20px" }}
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.primary} fontSize={{ base: "md", sm: "lg" }}>
{mode === 'buy' ? '购买席位' : '卖出席位'}
</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color={forumColors.text.primary} />
<ModalBody py={{ base: "4", sm: "6" }} px={{ base: "4", sm: "6" }}>
<VStack spacing="5" align="stretch">
{/* 话题标题 */}
<Box
bg={forumColors.background.hover}
borderRadius="lg"
p={{ base: "3", sm: "3" }}
border="1px solid"
borderColor={forumColors.border.default}
>
<Text fontSize={{ base: "xs", sm: "sm" }} fontWeight="600" color={forumColors.text.primary} lineHeight="1.5">
{topic.title}
</Text>
</Box>
{/* 选择方向 */}
<Box>
<Text fontSize={{ base: "sm", sm: "sm" }} fontWeight="600" color={forumColors.text.primary} mb={{ base: "2", sm: "3" }}>
选择方向
</Text>
<RadioGroup value={selectedOption} onChange={setSelectedOption}>
<Stack spacing="3">
{/* Yes 选项 */}
<MotionBox
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Box
bg={
selectedOption === 'yes'
? 'linear-gradient(135deg, rgba(72, 187, 120, 0.2) 0%, rgba(72, 187, 120, 0.1) 100%)'
: forumColors.background.hover
}
border="2px solid"
borderColor={selectedOption === 'yes' ? 'green.400' : forumColors.border.default}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
cursor="pointer"
onClick={() => setSelectedOption('yes')}
minH={{ base: "auto", sm: "auto" }}
>
<Flex justify="space-between" align="center">
<HStack spacing={{ base: "2", sm: "3" }}>
<Radio value="yes" colorScheme="green" size={{ base: "md", sm: "lg" }} />
<VStack align="start" spacing="0">
<HStack spacing="2">
<Icon as={TrendingUp} boxSize={{ base: "14px", sm: "16px" }} color="green.500" />
<Text fontWeight="600" color="green.600" fontSize={{ base: "sm", sm: "md" }}>
看涨 / Yes
</Text>
</HStack>
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary}>
{positions.yes.total_shares}份持仓
</Text>
</VStack>
</HStack>
<VStack align="end" spacing="0">
<Text fontSize={{ base: "lg", sm: "xl" }} fontWeight="bold" color="green.600">
{Math.round(positions.yes.current_price)}
</Text>
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary}>
积分/
</Text>
</VStack>
</Flex>
</Box>
</MotionBox>
{/* No 选项 */}
<MotionBox
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Box
bg={
selectedOption === 'no'
? 'linear-gradient(135deg, rgba(245, 101, 101, 0.2) 0%, rgba(245, 101, 101, 0.1) 100%)'
: forumColors.background.hover
}
border="2px solid"
borderColor={selectedOption === 'no' ? 'red.400' : forumColors.border.default}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
cursor="pointer"
onClick={() => setSelectedOption('no')}
minH={{ base: "auto", sm: "auto" }}
>
<Flex justify="space-between" align="center">
<HStack spacing={{ base: "2", sm: "3" }}>
<Radio value="no" colorScheme="red" size={{ base: "md", sm: "lg" }} />
<VStack align="start" spacing="0">
<HStack spacing="2">
<Icon as={TrendingDown} boxSize={{ base: "14px", sm: "16px" }} color="red.500" />
<Text fontWeight="600" color="red.600" fontSize={{ base: "sm", sm: "md" }}>
看跌 / No
</Text>
</HStack>
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary}>
{positions.no.total_shares}份持仓
</Text>
</VStack>
</HStack>
<VStack align="end" spacing="0">
<Text fontSize={{ base: "lg", sm: "xl" }} fontWeight="bold" color="red.600">
{Math.round(positions.no.current_price)}
</Text>
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary}>
积分/
</Text>
</VStack>
</Flex>
</Box>
</MotionBox>
</Stack>
</RadioGroup>
</Box>
{/* 购买份额 */}
<Box>
<Flex justify="space-between" mb={{ base: "2", sm: "3" }}>
<Text fontSize={{ base: "sm", sm: "sm" }} fontWeight="600" color={forumColors.text.primary}>
{mode === 'buy' ? '购买份额' : '卖出份额'}
</Text>
<Text fontSize={{ base: "sm", sm: "sm" }} color={forumColors.text.secondary}>
{shares}
</Text>
</Flex>
<Slider
value={shares}
onChange={setShares}
min={1}
max={maxShares}
step={1}
focusThumbOnChange={false}
>
<SliderTrack bg={forumColors.background.hover} h={{ base: "2", sm: "1.5" }}>
<SliderFilledTrack bg={forumColors.gradients.goldPrimary} />
</SliderTrack>
<SliderThumb boxSize={{ base: "7", sm: "6" }} bg={forumColors.primary[500]}>
<Box as={Icon} as={DollarSign} boxSize={{ base: "14px", sm: "12px" }} color="white" />
</SliderThumb>
</Slider>
<HStack justify="space-between" mt="2" fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.tertiary}>
<Text>1</Text>
<Text>{maxShares} (最大)</Text>
</HStack>
{mode === 'sell' && userPosition && (
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.secondary} mt="2">
你的持仓{userPosition.shares} · 平均成本{Math.round(userPosition.avg_cost)}积分/
</Text>
)}
</Box>
{/* 费用明细 */}
<Box
bg={forumColors.gradients.goldSubtle}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="lg"
p={{ base: "3", sm: "4" }}
>
<VStack spacing={{ base: "1.5", sm: "2" }} align="stretch">
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>
{mode === 'buy' ? '购买成本' : '卖出收益'}
</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round(cost)} 积分
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>平均价格</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round(avgPrice)} 积分/
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>交易税 (2%)</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{Math.round(tax)} 积分
</Text>
</Flex>
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt={{ base: "1.5", sm: "2" }} mt="1">
<Flex justify="space-between">
<Text fontWeight="bold" color={forumColors.text.primary} fontSize={{ base: "sm", sm: "md" }}>
{mode === 'buy' ? '总计' : '净收益'}
</Text>
<HStack spacing="1">
<Icon as={DollarSign} boxSize={{ base: "16px", sm: "20px" }} color={forumColors.primary[500]} />
<Text fontSize={{ base: "xl", sm: "2xl" }} fontWeight="bold" color={forumColors.primary[500]}>
{Math.round(totalCost)}
</Text>
<Text fontSize={{ base: "xs", sm: "sm" }} color={forumColors.text.secondary}>
积分
</Text>
</HStack>
</Flex>
</Box>
{/* 余额提示 */}
<Box borderTop="1px solid" borderColor={forumColors.border.default} pt={{ base: "1.5", sm: "2" }}>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }}>
<Text color={forumColors.text.secondary}>你的余额</Text>
<Text fontWeight="600" color={forumColors.text.primary}>
{userAccount.balance} 积分
</Text>
</Flex>
<Flex justify="space-between" fontSize={{ base: "xs", sm: "sm" }} mt="1">
<Text color={forumColors.text.secondary}>
{mode === 'buy' ? '交易后:' : '交易后:'}
</Text>
<Text
fontWeight="600"
color={
mode === 'buy'
? userAccount.balance >= totalCost
? forumColors.success[500]
: forumColors.error[500]
: forumColors.success[500]
}
>
{mode === 'buy'
? userAccount.balance - totalCost
: userAccount.balance + totalCost}{' '}
积分
</Text>
</Flex>
</Box>
</VStack>
</Box>
{/* 警告提示 */}
{!tradeCheck.ok && (
<Box
bg="red.50"
border="1px solid"
borderColor="red.200"
borderRadius="lg"
p={{ base: "2.5", sm: "3" }}
>
<HStack spacing="2">
<Icon as={AlertCircle} boxSize={{ base: "14px", sm: "16px" }} color="red.500" />
<Text fontSize={{ base: "xs", sm: "sm" }} color="red.600" fontWeight="600">
{tradeCheck.reason}
</Text>
</HStack>
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter
borderTop="1px solid"
borderColor={forumColors.border.default}
py={{ base: "3", sm: "4" }}
px={{ base: "4", sm: "6" }}
>
<HStack spacing={{ base: "2", sm: "3" }} w="full" justify="flex-end">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
h={{ base: "10", sm: "auto" }}
fontSize={{ base: "sm", sm: "md" }}
>
取消
</Button>
<Button
bg={mode === 'buy' ? forumColors.gradients.goldPrimary : 'red.500'}
color="white"
fontWeight="bold"
onClick={handleTrade}
isLoading={isSubmitting}
loadingText={mode === 'buy' ? '购买中...' : '卖出中...'}
isDisabled={!tradeCheck.ok}
_hover={{
opacity: 0.9,
transform: 'translateY(-2px)',
}}
_active={{ transform: 'translateY(0)' }}
h={{ base: "11", sm: "auto" }}
fontSize={{ base: "sm", sm: "md" }}
px={{ base: "6", sm: "4" }}
>
{mode === 'buy' ? `购买 ${shares}` : `卖出 ${shares}`}
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default TradeModal;

View File

@@ -22,26 +22,40 @@ import {
useDisclosure,
Flex,
Badge,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Icon,
} from '@chakra-ui/react';
import { Search, PenSquare, TrendingUp, Clock, Heart } from 'lucide-react';
import { Search, PenSquare, TrendingUp, Clock, Heart, Zap, HelpCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { forumColors } from '@theme/forumTheme';
import { getPosts, searchPosts } from '@services/elasticsearchService';
import { getTopics } from '@services/predictionMarketService.api';
import PostCard from './components/PostCard';
import PredictionTopicCard from './components/PredictionTopicCard';
import CreatePostModal from './components/CreatePostModal';
import CreatePredictionModal from './components/CreatePredictionModal';
import PredictionGuideModal from './components/PredictionGuideModal';
const MotionBox = motion(Box);
const ValueForum = () => {
const [posts, setPosts] = useState([]);
const [predictionTopics, setPredictionTopics] = useState([]);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useState('');
const [sortBy, setSortBy] = useState('created_at');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [activeTab, setActiveTab] = useState(0);
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isPostModalOpen, onOpen: onPostModalOpen, onClose: onPostModalClose } = useDisclosure();
const { isOpen: isPredictionModalOpen, onOpen: onPredictionModalOpen, onClose: onPredictionModalClose } = useDisclosure();
const { isOpen: isGuideModalOpen, onOpen: onGuideModalOpen, onClose: onGuideModalClose } = useDisclosure();
// 获取帖子列表
const fetchPosts = async (currentPage = 1, reset = false) => {
@@ -78,10 +92,29 @@ const ValueForum = () => {
}
};
// 获取预测话题列表
const fetchPredictionTopics = async () => {
try {
setLoading(true);
const response = await getTopics({ status: 'active', sort_by: sortBy });
if (response.success) {
setPredictionTopics(response.data);
}
} catch (error) {
console.error('获取预测话题失败:', error);
} finally {
setLoading(false);
}
};
// 初始化加载
useEffect(() => {
fetchPosts(1, true);
}, [sortBy]);
if (activeTab === 0) {
fetchPosts(1, true);
} else {
fetchPredictionTopics();
}
}, [sortBy, activeTab]);
// 搜索处理
const handleSearch = () => {
@@ -102,6 +135,11 @@ const ValueForum = () => {
fetchPosts(1, true);
};
// 预测话题创建成功回调
const handlePredictionCreated = (newTopic) => {
setPredictionTopics((prev) => [newTopic, ...prev]);
};
// 排序选项
const sortOptions = [
{ value: 'created_at', label: '最新发布', icon: Clock },
@@ -143,21 +181,59 @@ const ValueForum = () => {
</VStack>
{/* 发帖按钮 */}
<Button
leftIcon={<PenSquare size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
size="lg"
fontWeight="bold"
onClick={onOpen}
_hover={{
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
}}
_active={{ transform: 'translateY(0)' }}
>
发布帖子
</Button>
<HStack spacing="3">
<Button
leftIcon={<HelpCircle size={18} />}
variant="outline"
color={forumColors.primary[500]}
borderColor={forumColors.primary[500]}
size="lg"
fontWeight="bold"
onClick={onGuideModalOpen}
_hover={{
transform: 'translateY(-2px)',
bg: forumColors.primary[50],
borderColor: forumColors.primary[600],
}}
_active={{ transform: 'translateY(0)' }}
>
玩法说明
</Button>
<Button
leftIcon={<PenSquare size={18} />}
bg={forumColors.background.card}
color={forumColors.text.primary}
size="lg"
fontWeight="bold"
border="1px solid"
borderColor={forumColors.border.default}
onClick={onPostModalOpen}
_hover={{
transform: 'translateY(-2px)',
borderColor: forumColors.border.gold,
}}
_active={{ transform: 'translateY(0)' }}
>
发布帖子
</Button>
<Button
leftIcon={<Zap size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
size="lg"
fontWeight="bold"
onClick={onPredictionModalOpen}
_hover={{
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
}}
_active={{ transform: 'translateY(0)' }}
>
发起预测
</Button>
</HStack>
</Flex>
{/* 搜索和筛选栏 */}
@@ -224,86 +300,196 @@ const ValueForum = () => {
</VStack>
</MotionBox>
{/* 帖子网格 */}
{loading && page === 1 ? (
<Center py="20">
<VStack spacing="4">
<Spinner
size="xl"
thickness="4px"
speed="0.8s"
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.secondary}>加载中...</Text>
</VStack>
</Center>
) : posts.length === 0 ? (
<Center py="20">
<VStack spacing="4">
<Text color={forumColors.text.secondary} fontSize="lg">
{searchKeyword ? '未找到相关帖子' : '暂无帖子,快来发布第一篇吧!'}
</Text>
{!searchKeyword && (
<Button
leftIcon={<PenSquare size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
onClick={onOpen}
_hover={{ opacity: 0.9 }}
{/* 标签页 */}
<Tabs
index={activeTab}
onChange={setActiveTab}
variant="soft-rounded"
colorScheme="yellow"
>
<TabList mb="8" bg={forumColors.background.card} p="2" borderRadius="xl">
<Tab
_selected={{
bg: forumColors.gradients.goldPrimary,
color: forumColors.background.main,
}}
>
<HStack spacing="2">
<Icon as={PenSquare} boxSize="16px" />
<Text>社区帖子</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: forumColors.gradients.goldPrimary,
color: forumColors.background.main,
}}
>
<HStack spacing="2">
<Icon as={Zap} boxSize="16px" />
<Text>预测市场</Text>
<Badge
bg="red.500"
color="white"
borderRadius="full"
px="2"
fontSize="xs"
>
发布帖子
</Button>
)}
</VStack>
</Center>
) : (
<>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="6">
<AnimatePresence>
{posts.map((post, index) => (
<MotionBox
key={post.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<PostCard post={post} />
</MotionBox>
))}
</AnimatePresence>
</SimpleGrid>
NEW
</Badge>
</HStack>
</Tab>
</TabList>
{/* 加载更多按钮 */}
{hasMore && (
<Center mt="10">
<Button
onClick={loadMore}
isLoading={loading}
loadingText="加载中..."
bg={forumColors.background.card}
color={forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.default}
_hover={{
borderColor: forumColors.border.gold,
bg: forumColors.background.hover,
}}
>
加载更多
</Button>
</Center>
)}
</>
)}
<TabPanels>
{/* 普通帖子标签页 */}
<TabPanel p="0">
{loading && page === 1 ? (
<Center py="20">
<VStack spacing="4">
<Spinner
size="xl"
thickness="4px"
speed="0.8s"
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.secondary}>加载中...</Text>
</VStack>
</Center>
) : posts.length === 0 ? (
<Center py="20">
<VStack spacing="4">
<Text color={forumColors.text.secondary} fontSize="lg">
{searchKeyword ? '未找到相关帖子' : '暂无帖子,快来发布第一篇吧!'}
</Text>
{!searchKeyword && (
<Button
leftIcon={<PenSquare size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
onClick={onPostModalOpen}
_hover={{ opacity: 0.9 }}
>
发布帖子
</Button>
)}
</VStack>
</Center>
) : (
<>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="6">
<AnimatePresence>
{posts.map((post, index) => (
<MotionBox
key={post.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<PostCard post={post} />
</MotionBox>
))}
</AnimatePresence>
</SimpleGrid>
{/* 加载更多按钮 */}
{hasMore && (
<Center mt="10">
<Button
onClick={loadMore}
isLoading={loading}
loadingText="加载中..."
bg={forumColors.background.card}
color={forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.default}
_hover={{
borderColor: forumColors.border.gold,
bg: forumColors.background.hover,
}}
>
加载更多
</Button>
</Center>
)}
</>
)}
</TabPanel>
{/* 预测市场标签页 */}
<TabPanel p="0">
{loading ? (
<Center py="20">
<VStack spacing="4">
<Spinner
size="xl"
thickness="4px"
speed="0.8s"
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.secondary}>加载中...</Text>
</VStack>
</Center>
) : predictionTopics.length === 0 ? (
<Center py="20">
<VStack spacing="4">
<Icon as={Zap} boxSize="48px" color={forumColors.text.tertiary} />
<Text color={forumColors.text.secondary} fontSize="lg">
暂无预测话题快来发起第一个吧
</Text>
<Button
leftIcon={<Zap size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
onClick={onPredictionModalOpen}
_hover={{ opacity: 0.9 }}
>
发起预测
</Button>
</VStack>
</Center>
) : (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing="6">
<AnimatePresence>
{predictionTopics.map((topic, index) => (
<MotionBox
key={topic.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<PredictionTopicCard topic={topic} />
</MotionBox>
))}
</AnimatePresence>
</SimpleGrid>
)}
</TabPanel>
</TabPanels>
</Tabs>
</Container>
{/* 发帖模态框 */}
<CreatePostModal
isOpen={isOpen}
onClose={onClose}
isOpen={isPostModalOpen}
onClose={onPostModalClose}
onPostCreated={handlePostCreated}
/>
{/* 发起预测模态框 */}
<CreatePredictionModal
isOpen={isPredictionModalOpen}
onClose={onPredictionModalClose}
onTopicCreated={handlePredictionCreated}
/>
{/* 玩法说明模态框 */}
<PredictionGuideModal
isOpen={isGuideModalOpen}
onClose={onGuideModalClose}
/>
</Box>
);
};