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:
313
src/views/AgentChat/README.md
Normal file
313
src/views/AgentChat/README.md
Normal 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 模板开发。
|
||||
117
src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js
Normal file
117
src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js
Normal 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;
|
||||
248
src/views/AgentChat/components/ChatArea/MessageRenderer.js
Normal file
248
src/views/AgentChat/components/ChatArea/MessageRenderer.js
Normal 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;
|
||||
477
src/views/AgentChat/components/ChatArea/index.js
Normal file
477
src/views/AgentChat/components/ChatArea/index.js
Normal 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;
|
||||
76
src/views/AgentChat/components/LeftSidebar/SessionCard.js
Normal file
76
src/views/AgentChat/components/LeftSidebar/SessionCard.js
Normal 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;
|
||||
314
src/views/AgentChat/components/LeftSidebar/index.js
Normal file
314
src/views/AgentChat/components/LeftSidebar/index.js
Normal file
@@ -0,0 +1,314 @@
|
||||
// src/views/AgentChat/components/LeftSidebar/index.js
|
||||
// 左侧栏组件 - 对话历史列表
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Input,
|
||||
Avatar,
|
||||
Badge,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
HStack,
|
||||
VStack,
|
||||
Flex,
|
||||
} from '@chakra-ui/react';
|
||||
import { MessageSquare, Plus, Search, ChevronLeft } from 'lucide-react';
|
||||
import { animations } from '../../constants/animations';
|
||||
import { groupSessionsByDate } from '../../utils/sessionUtils';
|
||||
import SessionCard from './SessionCard';
|
||||
|
||||
/**
|
||||
* LeftSidebar - 左侧栏组件
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 侧边栏是否展开
|
||||
* @param {Function} props.onClose - 关闭侧边栏回调
|
||||
* @param {Array} props.sessions - 会话列表
|
||||
* @param {string|null} props.currentSessionId - 当前选中的会话 ID
|
||||
* @param {Function} props.onSessionSwitch - 切换会话回调
|
||||
* @param {Function} props.onNewSession - 新建会话回调
|
||||
* @param {boolean} props.isLoadingSessions - 会话加载中状态
|
||||
* @param {Object} props.user - 用户信息
|
||||
* @returns {JSX.Element|null}
|
||||
*/
|
||||
const LeftSidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
onSessionSwitch,
|
||||
onNewSession,
|
||||
isLoadingSessions,
|
||||
user,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 按日期分组会话
|
||||
const sessionGroups = groupSessionsByDate(sessions);
|
||||
|
||||
// 搜索过滤
|
||||
const filteredSessions = searchQuery
|
||||
? sessions.filter(
|
||||
(s) =>
|
||||
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.session_id?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: sessions;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={animations.slideInLeft}
|
||||
>
|
||||
<Box
|
||||
w="320px"
|
||||
h="100%"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
bg="rgba(17, 24, 39, 0.8)"
|
||||
backdropFilter="blur(20px) saturate(180%)"
|
||||
borderRight="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="4px 0 24px rgba(0, 0, 0, 0.3)"
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<Box p={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<HStack spacing={2}>
|
||||
<MessageSquare className="w-5 h-5" color="#60A5FA" />
|
||||
<Text
|
||||
fontWeight="semibold"
|
||||
bgGradient="linear(to-r, blue.300, purple.300)"
|
||||
bgClip="text"
|
||||
fontSize="md"
|
||||
>
|
||||
对话历史
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="新建对话">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={onNewSession}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
color="gray.300"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
_hover={{
|
||||
bg: 'rgba(59, 130, 246, 0.2)',
|
||||
borderColor: 'blue.400',
|
||||
color: 'blue.300',
|
||||
boxShadow: '0 0 12px rgba(59, 130, 246, 0.3)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
<Tooltip label="收起侧边栏">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<ChevronLeft className="w-4 h-4" />}
|
||||
onClick={onClose}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
color="gray.300"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
_hover={{
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'purple.400',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<Box position="relative">
|
||||
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)" zIndex={1}>
|
||||
<Search className="w-4 h-4" color="#9CA3AF" />
|
||||
</Box>
|
||||
<Input
|
||||
placeholder="搜索对话..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
pl={10}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.500' }}
|
||||
_hover={{
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
}}
|
||||
_focus={{
|
||||
borderColor: 'purple.400',
|
||||
boxShadow:
|
||||
'0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)',
|
||||
bg: 'rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<Box flex={1} p={3} overflowY="auto">
|
||||
{/* 按日期分组显示会话 */}
|
||||
{sessionGroups.today.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
今天
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessionGroups.today.map((session, idx) => (
|
||||
<motion.div
|
||||
key={session.session_id}
|
||||
custom={idx}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
>
|
||||
<SessionCard
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sessionGroups.yesterday.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
昨天
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessionGroups.yesterday.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sessionGroups.thisWeek.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
本周
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessionGroups.thisWeek.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sessionGroups.older.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
|
||||
更早
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{sessionGroups.older.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.session_id}
|
||||
onPress={() => onSessionSwitch(session.session_id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoadingSessions && (
|
||||
<Flex justify="center" p={4}>
|
||||
<Spinner
|
||||
size="md"
|
||||
color="purple.500"
|
||||
emptyColor="gray.700"
|
||||
thickness="3px"
|
||||
speed="0.65s"
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{sessions.length === 0 && !isLoadingSessions && (
|
||||
<VStack textAlign="center" py={8} color="gray.500" fontSize="sm" spacing={2}>
|
||||
<MessageSquare className="w-8 h-8" style={{ opacity: 0.5, margin: '0 auto' }} />
|
||||
<Text>还没有对话历史</Text>
|
||||
<Text fontSize="xs">开始一个新对话吧!</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
<Box p={4} borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||
<HStack spacing={3}>
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.nickname}
|
||||
size="sm"
|
||||
bgGradient="linear(to-br, blue.500, purple.600)"
|
||||
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
|
||||
/>
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
|
||||
{user?.nickname || '未登录'}
|
||||
</Text>
|
||||
<Badge
|
||||
bgGradient="linear(to-r, blue.500, purple.500)"
|
||||
color="white"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
fontWeight="semibold"
|
||||
textTransform="none"
|
||||
>
|
||||
{user?.subscription_type || 'free'}
|
||||
</Badge>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftSidebar;
|
||||
499
src/views/AgentChat/components/RightSidebar/index.js
Normal file
499
src/views/AgentChat/components/RightSidebar/index.js
Normal 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;
|
||||
101
src/views/AgentChat/constants/animations.ts
Normal file
101
src/views/AgentChat/constants/animations.ts
Normal 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 },
|
||||
},
|
||||
};
|
||||
22
src/views/AgentChat/constants/index.ts
Normal file
22
src/views/AgentChat/constants/index.ts
Normal 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';
|
||||
93
src/views/AgentChat/constants/messageTypes.ts
Normal file
93
src/views/AgentChat/constants/messageTypes.ts
Normal 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;
|
||||
63
src/views/AgentChat/constants/models.ts
Normal file
63
src/views/AgentChat/constants/models.ts
Normal 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);
|
||||
};
|
||||
23
src/views/AgentChat/constants/quickQuestions.ts
Normal file
23
src/views/AgentChat/constants/quickQuestions.ts
Normal 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: '📊' },
|
||||
];
|
||||
249
src/views/AgentChat/constants/tools.ts
Normal file
249
src/views/AgentChat/constants/tools.ts
Normal 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] || [];
|
||||
};
|
||||
34
src/views/AgentChat/hooks/index.ts
Normal file
34
src/views/AgentChat/hooks/index.ts
Normal 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';
|
||||
289
src/views/AgentChat/hooks/useAgentChat.ts
Normal file
289
src/views/AgentChat/hooks/useAgentChat.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
189
src/views/AgentChat/hooks/useAgentSessions.ts
Normal file
189
src/views/AgentChat/hooks/useAgentSessions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
38
src/views/AgentChat/hooks/useAutoScroll.ts
Normal file
38
src/views/AgentChat/hooks/useAutoScroll.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
131
src/views/AgentChat/hooks/useFileUpload.ts
Normal file
131
src/views/AgentChat/hooks/useFileUpload.ts
Normal 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
1529
src/views/AgentChat/index.js.bak
Normal file
1529
src/views/AgentChat/index.js.bak
Normal file
File diff suppressed because it is too large
Load Diff
1173
src/views/AgentChat/index.js.bak2
Normal file
1173
src/views/AgentChat/index.js.bak2
Normal file
File diff suppressed because it is too large
Load Diff
816
src/views/AgentChat/index.js.bak3
Normal file
816
src/views/AgentChat/index.js.bak3
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
45
src/views/AgentChat/utils/sessionUtils.js
Normal file
45
src/views/AgentChat/utils/sessionUtils.js
Normal 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;
|
||||
};
|
||||
@@ -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} // 传递用户点击的图表类型
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
383
src/views/Profile/index.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
652
src/views/ValueForum/PredictionTopicDetail.js
Normal file
652
src/views/ValueForum/PredictionTopicDetail.js
Normal 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;
|
||||
464
src/views/ValueForum/components/CommentInvestModal.js
Normal file
464
src/views/ValueForum/components/CommentInvestModal.js
Normal 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;
|
||||
@@ -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 URL(JPEG 格式,质量 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) => ({
|
||||
|
||||
401
src/views/ValueForum/components/CreatePredictionModal.js
Normal file
401
src/views/ValueForum/components/CreatePredictionModal.js
Normal 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;
|
||||
475
src/views/ValueForum/components/PredictionCommentSection.js
Normal file
475
src/views/ValueForum/components/PredictionCommentSection.js
Normal 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;
|
||||
1241
src/views/ValueForum/components/PredictionGuideModal.js
Normal file
1241
src/views/ValueForum/components/PredictionGuideModal.js
Normal file
File diff suppressed because it is too large
Load Diff
327
src/views/ValueForum/components/PredictionTopicCard.js
Normal file
327
src/views/ValueForum/components/PredictionTopicCard.js
Normal 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;
|
||||
547
src/views/ValueForum/components/TradeModal.js
Normal file
547
src/views/ValueForum/components/TradeModal.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user