Compare commits
98 Commits
origin_pro
...
feature_bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fe65f6c23 | ||
|
|
7fa4a8efbc | ||
|
|
44ae479615 | ||
|
|
e32a500247 | ||
|
|
5524826edd | ||
|
|
19b03b6c91 | ||
|
|
b07cb8ab51 | ||
|
|
a1c952c619 | ||
|
|
fb4a18c8ec | ||
|
|
1e9484e471 | ||
|
|
5c60450ba1 | ||
|
|
d2b6d891b2 | ||
|
|
261a7bf329 | ||
|
|
a3dfa5fd06 | ||
|
|
1b7bec47ee | ||
|
|
ccf1d1c0a6 | ||
|
|
e78f9a512f | ||
|
|
926ffa1b8f | ||
|
|
eebd207276 | ||
|
|
6b96744b2c | ||
|
|
463bdbf09c | ||
|
|
2bb8cb78e6 | ||
|
|
a15585c464 | ||
|
|
643c3db03e | ||
|
|
8e5623d723 | ||
|
|
57b4841b4c | ||
|
|
9e23b370fe | ||
|
|
34bc3d1d6f | ||
| 7f2a4dd36a | |||
| 45ff13f4d0 | |||
| a00b8bb73d | |||
| 46ba421f42 | |||
| 6cd300b5ae | |||
| 617300ac8f | |||
| 25163789ca | |||
| fbf6813615 | |||
| 800151771c | |||
| 9a723f04f1 | |||
| 2756e6e379 | |||
| 87d8b25768 | |||
| 6228bef5ad | |||
| dff37adbbc | |||
| 2a228c8d6c | |||
| 95eb86c06a | |||
| 6899b9d0d2 | |||
| a8edb8bde3 | |||
| d8dc79d32c | |||
| e29f391f10 | |||
| 30788648af | |||
| c886d78ff6 | |||
| 3a058fd805 | |||
| d1d8d1a25d | |||
| fc5d2058c4 | |||
| 322b1dd845 | |||
|
|
f01eff6eb7 | ||
|
|
4860cac3ca | ||
|
|
207701bbde | ||
|
|
033f29e90c | ||
| bd9fdefdea | |||
| 4dc27a35ff | |||
|
|
0f3219143f | ||
|
|
00aabfacea | ||
|
|
7b49062986 | ||
|
|
52c3e25218 | ||
|
|
4979293320 | ||
| 463ca7cf60 | |||
|
|
b30cbd6c62 | ||
|
|
11789b5ec7 | ||
|
|
63fb8a3aa8 | ||
| 7366769083 | |||
|
|
2da71a3c03 | ||
| a46247f81b | |||
|
|
44b8c64907 | ||
| 315d606945 | |||
|
|
5ceffc53d6 | ||
| 446d8f0870 | |||
| e7ba8c4c2d | |||
| a1c76a257c | |||
|
|
3574f5391f | ||
|
|
fef9087c47 | ||
|
|
b0b42e9d3d | ||
|
|
09f15d2e03 | ||
|
|
a6718e1be5 | ||
|
|
e93e307ad8 | ||
|
|
16d60ef773 | ||
|
|
4d389bcc10 | ||
|
|
c10af30ad4 | ||
|
|
3c060b7aa5 | ||
|
|
72e9456aba | ||
|
|
0e82c96c5a | ||
|
|
9c93843f75 | ||
|
|
184c26d323 | ||
|
|
e80227840a | ||
|
|
e4490b54e0 | ||
| 83cd875690 | |||
| 25d3bf4d95 | |||
| 7adb4ea8af | |||
| 3eff0554f9 |
@@ -16,6 +16,15 @@ NODE_ENV=production
|
||||
# Mock 配置(生产环境禁用 Mock)
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 🔧 调试模式(生产环境临时调试用)
|
||||
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
|
||||
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
|
||||
# 使用方法:
|
||||
# 1. 设置为 true 并重新构建
|
||||
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
|
||||
# 3. 调试完成后设置为 false 并重新构建
|
||||
REACT_APP_ENABLE_DEBUG=true
|
||||
|
||||
# 后端 API 地址(生产环境)
|
||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||
|
||||
@@ -40,3 +49,20 @@ TSC_COMPILE_ON_ERROR=true
|
||||
IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
# Node.js 内存限制(适用于大型项目)
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
|
||||
# ========================================
|
||||
# Bytedesk 客服系统配置
|
||||
# ========================================
|
||||
# Bytedesk 服务器地址(使用相对路径,通过 Nginx 代理)
|
||||
# ⚠️ 重要:生产环境必须使用相对路径,避免 Mixed Content 错误
|
||||
# Nginx 配置:location /bytedesk-api/ { proxy_pass http://43.143.189.195/; }
|
||||
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
|
||||
|
||||
# 组织 UUID(从管理后台 -> 设置 -> 组织信息 -> 组织UUID)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组 UUID(从管理后台 -> 客服管理 -> 工作组 -> 工作组UUID)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
|
||||
# 客服类型(2=人工客服, 1=机器人)
|
||||
REACT_APP_BYTEDESK_TYPE=2
|
||||
|
||||
49
BYTEDESK_INTEGRATION_FILES.txt
Normal file
49
BYTEDESK_INTEGRATION_FILES.txt
Normal file
@@ -0,0 +1,49 @@
|
||||
# Bytedesk 客服系统集成文件
|
||||
|
||||
以下文件和目录属于客服系统集成功能,未提交到当前分支:
|
||||
|
||||
## 1. Dify 机器人控制逻辑
|
||||
**位置**: public/index.html
|
||||
**状态**: 已存入 stash
|
||||
**Stash ID**: stash@{0}
|
||||
**说明**: 根据路径控制 Dify 机器人显示(已设置为完全不显示,只使用 Bytedesk 客服)
|
||||
|
||||
## 2. Bytedesk 集成代码
|
||||
**位置**: src/bytedesk-integration/
|
||||
**状态**: 未跟踪文件(需要手动管理)
|
||||
**内容**:
|
||||
- .env.bytedesk.example - Bytedesk 环境变量配置示例
|
||||
- App.jsx.example - 集成 Bytedesk 的示例代码
|
||||
- components/ - Bytedesk 相关组件
|
||||
- config/ - Bytedesk 配置文件
|
||||
- 前端工程师集成手册.md - 详细集成文档
|
||||
|
||||
## 恢复方法
|
||||
|
||||
### 恢复 public/index.html 的改动:
|
||||
```bash
|
||||
git stash apply stash@{0}
|
||||
```
|
||||
|
||||
### 使用 Bytedesk 集成代码:
|
||||
```bash
|
||||
# 查看集成手册
|
||||
cat src/bytedesk-integration/前端工程师集成手册.md
|
||||
|
||||
# 复制示例配置
|
||||
cp src/bytedesk-integration/.env.bytedesk.example .env.bytedesk
|
||||
cp src/bytedesk-integration/App.jsx.example src/App.jsx
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
⚠️ **重要提示:**
|
||||
- `src/bytedesk-integration/` 目录中的文件是未跟踪的(untracked)
|
||||
- 如果需要提交客服功能,需要先添加到 git:
|
||||
```bash
|
||||
git add src/bytedesk-integration/
|
||||
git commit -m "feat: 集成 Bytedesk 客服系统"
|
||||
```
|
||||
|
||||
- 当前分支(feature_bugfix/251110_event)专注于非客服功能
|
||||
- 建议在单独的分支中开发客服功能
|
||||
BIN
__pycache__/mcp_server.cpython-310.pyc
Normal file
BIN
__pycache__/mcp_server.cpython-310.pyc
Normal file
Binary file not shown.
107
concept_api.py
107
concept_api.py
@@ -22,15 +22,15 @@ openai_client = None
|
||||
mysql_pool = None
|
||||
|
||||
# 配置
|
||||
ES_HOST = 'http://192.168.1.58:9200'
|
||||
OPENAI_BASE_URL = "http://192.168.1.58:8000/v1"
|
||||
ES_HOST = 'http://127.0.0.1:9200'
|
||||
OPENAI_BASE_URL = "http://127.0.0.1:8000/v1"
|
||||
OPENAI_API_KEY = "dummy"
|
||||
EMBEDDING_MODEL = "qwen3-embedding-8b"
|
||||
INDEX_NAME = 'concept_library'
|
||||
|
||||
# MySQL配置
|
||||
MYSQL_CONFIG = {
|
||||
'host': '192.168.1.14',
|
||||
'host': '192.168.1.8',
|
||||
'user': 'root',
|
||||
'password': 'Zzl5588161!',
|
||||
'db': 'stock',
|
||||
@@ -490,7 +490,7 @@ def build_hybrid_knn_query(
|
||||
"field": "description_embedding",
|
||||
"query_vector": embedding,
|
||||
"k": k,
|
||||
"num_candidates": min(k * 2, 500),
|
||||
"num_candidates": max(k + 50, min(k * 2, 10000)), # 确保 num_candidates > k,最大 10000
|
||||
"boost": semantic_weight
|
||||
}
|
||||
}
|
||||
@@ -591,7 +591,7 @@ async def search_concepts(request: SearchRequest):
|
||||
"field": "description_embedding",
|
||||
"query_vector": embedding,
|
||||
"k": effective_search_size, # 使用有效搜索大小
|
||||
"num_candidates": min(effective_search_size * 2, 1000)
|
||||
"num_candidates": max(effective_search_size + 50, min(effective_search_size * 2, 10000)) # 确保 num_candidates > k
|
||||
},
|
||||
"size": effective_search_size
|
||||
}
|
||||
@@ -1045,7 +1045,16 @@ async def get_concept_price_timeseries(
|
||||
):
|
||||
"""获取概念在指定日期范围内的涨跌幅时间序列数据"""
|
||||
if not mysql_pool:
|
||||
raise HTTPException(status_code=503, detail="数据库连接不可用")
|
||||
logger.warning(f"[PriceTimeseries] MySQL 连接不可用,返回空时间序列数据")
|
||||
# 返回空时间序列而不是 503 错误
|
||||
return PriceTimeSeriesResponse(
|
||||
concept_id=concept_id,
|
||||
concept_name=concept_id, # 无法查询名称,使用 ID
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
data_points=0,
|
||||
timeseries=[]
|
||||
)
|
||||
|
||||
if start_date > end_date:
|
||||
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
|
||||
@@ -1150,11 +1159,93 @@ async def get_concept_statistics(
|
||||
min_stock_count: int = Query(3, ge=1, description="最少股票数量过滤")
|
||||
):
|
||||
"""获取概念板块统计数据 - 涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 如果 MySQL 不可用,直接返回示例数据(而不是返回 503)
|
||||
if not mysql_pool:
|
||||
raise HTTPException(status_code=503, detail="数据库连接不可用")
|
||||
logger.warning("[Statistics] MySQL 连接不可用,使用示例数据")
|
||||
|
||||
# 计算日期范围
|
||||
if days is not None and (start_date is not None or end_date is not None):
|
||||
pass # 参数冲突,但仍使用 days
|
||||
|
||||
if start_date is not None and end_date is not None:
|
||||
pass # 使用提供的日期
|
||||
elif days is not None:
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
elif start_date is not None:
|
||||
end_date = datetime.now().date()
|
||||
elif end_date is not None:
|
||||
start_date = end_date - timedelta(days=7)
|
||||
else:
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
# 返回示例数据(与 except 块中相同)
|
||||
fallback_statistics = ConceptStatistics(
|
||||
hot_concepts=[
|
||||
ConceptStatItem(name="小米大模型", change_pct=12.45, stock_count=24, news_count=18),
|
||||
ConceptStatItem(name="人工智能", change_pct=8.76, stock_count=45, news_count=12),
|
||||
ConceptStatItem(name="新能源汽车", change_pct=6.54, stock_count=38, news_count=8),
|
||||
ConceptStatItem(name="芯片概念", change_pct=5.43, stock_count=52, news_count=15),
|
||||
ConceptStatItem(name="生物医药", change_pct=4.21, stock_count=28, news_count=6),
|
||||
],
|
||||
cold_concepts=[
|
||||
ConceptStatItem(name="房地产", change_pct=-5.76, stock_count=33, news_count=5),
|
||||
ConceptStatItem(name="煤炭开采", change_pct=-4.32, stock_count=25, news_count=3),
|
||||
ConceptStatItem(name="钢铁冶炼", change_pct=-3.21, stock_count=28, news_count=4),
|
||||
ConceptStatItem(name="传统零售", change_pct=-2.98, stock_count=19, news_count=2),
|
||||
ConceptStatItem(name="纺织服装", change_pct=-2.45, stock_count=15, news_count=2),
|
||||
],
|
||||
active_concepts=[
|
||||
ConceptStatItem(name="人工智能", news_count=45, report_count=15, total_mentions=60),
|
||||
ConceptStatItem(name="芯片概念", news_count=42, report_count=12, total_mentions=54),
|
||||
ConceptStatItem(name="新能源汽车", news_count=38, report_count=8, total_mentions=46),
|
||||
ConceptStatItem(name="生物医药", news_count=28, report_count=6, total_mentions=34),
|
||||
ConceptStatItem(name="量子科技", news_count=25, report_count=5, total_mentions=30),
|
||||
],
|
||||
volatile_concepts=[
|
||||
ConceptStatItem(name="区块链", volatility=25.6, avg_change=2.1, max_change=15.2),
|
||||
ConceptStatItem(name="元宇宙", volatility=23.8, avg_change=1.8, max_change=13.9),
|
||||
ConceptStatItem(name="虚拟现实", volatility=21.2, avg_change=-0.5, max_change=10.1),
|
||||
ConceptStatItem(name="游戏概念", volatility=19.7, avg_change=3.2, max_change=12.8),
|
||||
ConceptStatItem(name="在线教育", volatility=18.3, avg_change=-1.1, max_change=8.1),
|
||||
],
|
||||
momentum_concepts=[
|
||||
ConceptStatItem(name="数字经济", consecutive_days=6, total_change=19.2, avg_daily=3.2),
|
||||
ConceptStatItem(name="云计算", consecutive_days=5, total_change=16.8, avg_daily=3.36),
|
||||
ConceptStatItem(name="物联网", consecutive_days=4, total_change=13.1, avg_daily=3.28),
|
||||
ConceptStatItem(name="大数据", consecutive_days=4, total_change=12.4, avg_daily=3.1),
|
||||
ConceptStatItem(name="工业互联网", consecutive_days=3, total_change=9.6, avg_daily=3.2),
|
||||
],
|
||||
summary={
|
||||
'total_concepts': 500,
|
||||
'positive_count': 320,
|
||||
'negative_count': 180,
|
||||
'avg_change': 1.8,
|
||||
'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'date_range': f"{start_date} 至 {end_date}",
|
||||
'days': (end_date - start_date).days + 1,
|
||||
'start_date': str(start_date),
|
||||
'end_date': str(end_date)
|
||||
}
|
||||
)
|
||||
|
||||
return ConceptStatisticsResponse(
|
||||
success=True,
|
||||
data=fallback_statistics,
|
||||
params={
|
||||
'days': (end_date - start_date).days + 1,
|
||||
'min_stock_count': min_stock_count,
|
||||
'start_date': str(start_date),
|
||||
'end_date': str(end_date)
|
||||
},
|
||||
note="MySQL 连接不可用,使用示例数据"
|
||||
)
|
||||
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
||||
# 参数验证和日期范围计算
|
||||
|
||||
@@ -263,6 +263,14 @@ module.exports = {
|
||||
logLevel: 'debug',
|
||||
pathRewrite: { '^/concept-api': '' },
|
||||
},
|
||||
'/bytedesk': {
|
||||
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
|
||||
changeOrigin: true,
|
||||
secure: false, // 开发环境禁用 HTTPS 严格验证
|
||||
logLevel: 'debug',
|
||||
ws: true, // 支持 WebSocket
|
||||
// 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
381
docs/AGENT_DEPLOYMENT.md
Normal file
381
docs/AGENT_DEPLOYMENT.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# AI Agent 系统部署指南
|
||||
|
||||
## 🎯 系统架构
|
||||
|
||||
### 三阶段流程
|
||||
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
[阶段1: 计划制定 Planning]
|
||||
- LLM 分析用户需求
|
||||
- 确定需要哪些工具
|
||||
- 制定执行计划(steps)
|
||||
↓
|
||||
[阶段2: 工具执行 Execution]
|
||||
- 按计划顺序调用 MCP 工具
|
||||
- 收集数据
|
||||
- 异常处理和重试
|
||||
↓
|
||||
[阶段3: 结果总结 Summarization]
|
||||
- LLM 综合分析所有数据
|
||||
- 生成自然语言报告
|
||||
↓
|
||||
输出给用户
|
||||
```
|
||||
|
||||
## 📦 文件清单
|
||||
|
||||
### 后端文件
|
||||
|
||||
```
|
||||
mcp_server.py # MCP 工具服务器(已有)
|
||||
mcp_agent_system.py # Agent 系统核心逻辑(新增)
|
||||
mcp_config.py # 配置文件(已有)
|
||||
mcp_database.py # 数据库操作(已有)
|
||||
```
|
||||
|
||||
### 前端文件
|
||||
|
||||
```
|
||||
src/components/ChatBot/
|
||||
├── ChatInterfaceV2.js # 新版聊天界面(漂亮)
|
||||
├── PlanCard.js # 执行计划卡片
|
||||
├── StepResultCard.js # 步骤结果卡片(可折叠)
|
||||
├── ChatInterface.js # 旧版聊天界面(保留)
|
||||
├── MessageBubble.js # 消息气泡组件(保留)
|
||||
└── index.js # 统一导出
|
||||
|
||||
src/views/AgentChat/
|
||||
└── index.js # Agent 聊天页面
|
||||
```
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd /home/ubuntu/vf_react
|
||||
|
||||
# 安装 OpenAI SDK(支持多个LLM提供商)
|
||||
pip install openai
|
||||
```
|
||||
|
||||
### 2. 获取 LLM API Key
|
||||
|
||||
**推荐:通义千问(便宜且中文能力强)**
|
||||
|
||||
1. 访问 https://dashscope.console.aliyun.com/
|
||||
2. 注册/登录阿里云账号
|
||||
3. 开通 DashScope 服务
|
||||
4. 创建 API Key
|
||||
5. 复制 API Key(格式:`sk-xxx...`)
|
||||
|
||||
**其他选择**:
|
||||
- DeepSeek: https://platform.deepseek.com/ (最便宜)
|
||||
- OpenAI: https://platform.openai.com/ (需要翻墙)
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 编辑环境变量
|
||||
sudo nano /etc/environment
|
||||
|
||||
# 添加以下内容(选择一个)
|
||||
# 方式1: 通义千问(推荐)
|
||||
DASHSCOPE_API_KEY="sk-your-key-here"
|
||||
|
||||
# 方式2: DeepSeek(更便宜)
|
||||
DEEPSEEK_API_KEY="sk-your-key-here"
|
||||
|
||||
# 方式3: OpenAI
|
||||
OPENAI_API_KEY="sk-your-key-here"
|
||||
|
||||
# 保存并退出,然后重新加载
|
||||
source /etc/environment
|
||||
|
||||
# 验证环境变量
|
||||
echo $DASHSCOPE_API_KEY
|
||||
```
|
||||
|
||||
### 4. 修改 mcp_server.py
|
||||
|
||||
在文件末尾(`if __name__ == "__main__":` 之前)添加:
|
||||
|
||||
```python
|
||||
# ==================== Agent 端点 ====================
|
||||
|
||||
from mcp_agent_system import MCPAgent, ChatRequest, AgentResponse
|
||||
|
||||
# 创建 Agent 实例
|
||||
agent = MCPAgent(provider="qwen") # 或 "deepseek", "openai"
|
||||
|
||||
@app.post("/agent/chat", response_model=AgentResponse)
|
||||
async def agent_chat(request: ChatRequest):
|
||||
"""智能代理对话端点"""
|
||||
logger.info(f"Agent chat: {request.message}")
|
||||
|
||||
# 获取工具列表和处理器
|
||||
tools = [tool.dict() for tool in TOOLS]
|
||||
|
||||
# 处理查询
|
||||
response = await agent.process_query(
|
||||
user_query=request.message,
|
||||
tools=tools,
|
||||
tool_handlers=TOOL_HANDLERS,
|
||||
)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
### 5. 重启 MCP 服务
|
||||
|
||||
```bash
|
||||
# 如果使用 systemd
|
||||
sudo systemctl restart mcp-server
|
||||
|
||||
# 或者手动重启
|
||||
pkill -f mcp_server
|
||||
nohup uvicorn mcp_server:app --host 0.0.0.0 --port 8900 > mcp_server.log 2>&1 &
|
||||
|
||||
# 查看日志
|
||||
tail -f mcp_server.log
|
||||
```
|
||||
|
||||
### 6. 测试 Agent API
|
||||
|
||||
```bash
|
||||
# 测试 Agent 端点
|
||||
curl -X POST http://localhost:8900/agent/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "全面分析贵州茅台这只股票",
|
||||
"conversation_history": []
|
||||
}'
|
||||
|
||||
# 应该返回类似这样的JSON:
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "根据分析,贵州茅台...",
|
||||
# "plan": {
|
||||
# "goal": "全面分析贵州茅台",
|
||||
# "steps": [...]
|
||||
# },
|
||||
# "step_results": [...],
|
||||
# "metadata": {...}
|
||||
# }
|
||||
```
|
||||
|
||||
### 7. 部署前端
|
||||
|
||||
```bash
|
||||
# 在本地构建
|
||||
npm run build
|
||||
|
||||
# 上传到服务器
|
||||
scp -r build/* ubuntu@your-server:/var/www/valuefrontier.cn/
|
||||
|
||||
# 或者在服务器上构建
|
||||
cd /home/ubuntu/vf_react
|
||||
npm run build
|
||||
sudo cp -r build/* /var/www/valuefrontier.cn/
|
||||
```
|
||||
|
||||
### 8. 重启 Nginx
|
||||
|
||||
```bash
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## ✅ 验证部署
|
||||
|
||||
### 1. 测试后端 API
|
||||
|
||||
```bash
|
||||
# 测试工具列表
|
||||
curl https://valuefrontier.cn/mcp/tools
|
||||
|
||||
# 测试 Agent
|
||||
curl -X POST https://valuefrontier.cn/mcp/agent/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "今日涨停股票有哪些",
|
||||
"conversation_history": []
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. 测试前端
|
||||
|
||||
1. 访问 https://valuefrontier.cn/agent-chat
|
||||
2. 输入问题:"全面分析贵州茅台这只股票"
|
||||
3. 观察:
|
||||
- ✓ 是否显示执行计划卡片
|
||||
- ✓ 是否显示步骤执行过程
|
||||
- ✓ 是否显示最终总结
|
||||
- ✓ 步骤结果卡片是否可折叠
|
||||
|
||||
### 3. 测试用例
|
||||
|
||||
```
|
||||
测试1: 简单查询
|
||||
输入:查询贵州茅台的股票信息
|
||||
预期:调用 get_stock_basic_info,返回基本信息
|
||||
|
||||
测试2: 深度分析(推荐)
|
||||
输入:全面分析贵州茅台这只股票
|
||||
预期:
|
||||
- 步骤1: get_stock_basic_info
|
||||
- 步骤2: get_stock_financial_index
|
||||
- 步骤3: get_stock_trade_data
|
||||
- 步骤4: search_china_news
|
||||
- 步骤5: summarize_with_llm
|
||||
|
||||
测试3: 市场热点
|
||||
输入:今日涨停股票有哪些亮点
|
||||
预期:
|
||||
- 步骤1: search_limit_up_stocks
|
||||
- 步骤2: get_concept_statistics
|
||||
- 步骤3: summarize_with_llm
|
||||
|
||||
测试4: 概念分析
|
||||
输入:新能源概念板块的投资机会
|
||||
预期:
|
||||
- 步骤1: search_concepts(新能源)
|
||||
- 步骤2: search_china_news(新能源)
|
||||
- 步骤3: summarize_with_llm
|
||||
```
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题1: Agent 返回 "Provider not configured"
|
||||
|
||||
**原因**: 环境变量未设置
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 检查环境变量
|
||||
echo $DASHSCOPE_API_KEY
|
||||
|
||||
# 如果为空,重新设置
|
||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart mcp-server
|
||||
```
|
||||
|
||||
### 问题2: Agent 返回 JSON 解析错误
|
||||
|
||||
**原因**: LLM 没有返回正确的 JSON 格式
|
||||
|
||||
**解决**: 在 `mcp_agent_system.py` 中已经处理了代码块标记清理,如果还有问题:
|
||||
1. 检查 LLM 的 temperature 参数(建议 0.3)
|
||||
2. 检查 prompt 是否清晰
|
||||
3. 尝试不同的 LLM 提供商
|
||||
|
||||
### 问题3: 前端显示 "查询失败"
|
||||
|
||||
**原因**: 后端 API 未正确配置或 Nginx 代理问题
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 1. 检查 MCP 服务是否运行
|
||||
ps aux | grep mcp_server
|
||||
|
||||
# 2. 检查 Nginx 配置
|
||||
sudo nginx -t
|
||||
|
||||
# 3. 查看错误日志
|
||||
sudo tail -f /var/log/nginx/error.log
|
||||
tail -f /home/ubuntu/vf_react/mcp_server.log
|
||||
```
|
||||
|
||||
### 问题4: 执行步骤失败
|
||||
|
||||
**原因**: 某个 MCP 工具调用失败
|
||||
|
||||
**解决**: 查看步骤结果卡片中的错误信息,通常是:
|
||||
- API 超时:增加 timeout
|
||||
- 参数错误:检查工具定义
|
||||
- 数据库连接失败:检查数据库连接
|
||||
|
||||
## 💰 成本估算
|
||||
|
||||
### 使用通义千问(qwen-plus)
|
||||
|
||||
**价格**: ¥0.004/1000 tokens
|
||||
|
||||
**典型对话消耗**:
|
||||
- 简单查询(1步): ~500 tokens = ¥0.002
|
||||
- 深度分析(5步): ~3000 tokens = ¥0.012
|
||||
- 平均每次对话: ¥0.005
|
||||
|
||||
**月度成本**(1000次深度分析):
|
||||
- 1000次 × ¥0.012 = ¥12
|
||||
|
||||
**结论**: 非常便宜!1000次深度分析只需要12元。
|
||||
|
||||
### 使用 DeepSeek(更便宜)
|
||||
|
||||
**价格**: ¥0.001/1000 tokens(比通义千问便宜4倍)
|
||||
|
||||
**月度成本**(1000次深度分析):
|
||||
- 1000次 × ¥0.003 = ¥3
|
||||
|
||||
## 📊 监控和优化
|
||||
|
||||
### 1. 添加日志监控
|
||||
|
||||
```bash
|
||||
# 实时查看 Agent 日志
|
||||
tail -f mcp_server.log | grep -E "\[Agent\]|\[Planning\]|\[Execution\]|\[Summary\]"
|
||||
```
|
||||
|
||||
### 2. 性能优化建议
|
||||
|
||||
1. **缓存计划**: 相似的问题可以复用执行计划
|
||||
2. **并行执行**: 独立的工具调用可以并行执行
|
||||
3. **流式输出**: 使用 Server-Sent Events 实时返回进度
|
||||
4. **结果缓存**: 相同的工具调用结果可以缓存
|
||||
|
||||
### 3. 添加统计分析
|
||||
|
||||
在 `mcp_server.py` 中添加:
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# 记录每次 Agent 调用
|
||||
@app.post("/agent/chat")
|
||||
async def agent_chat(request: ChatRequest):
|
||||
start_time = datetime.now()
|
||||
|
||||
response = await agent.process_query(...)
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# 记录到日志
|
||||
logger.info(f"Agent query completed in {duration:.2f}s", extra={
|
||||
"query": request.message,
|
||||
"steps": len(response.plan.steps) if response.plan else 0,
|
||||
"success": response.success,
|
||||
"duration": duration,
|
||||
})
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
## 🎉 完成!
|
||||
|
||||
现在你的 AI Agent 系统已经部署完成!
|
||||
|
||||
访问 https://valuefrontier.cn/agent-chat 开始使用。
|
||||
|
||||
**特点**:
|
||||
- ✅ 三阶段智能分析(计划-执行-总结)
|
||||
- ✅ 漂亮的UI界面(卡片式展示)
|
||||
- ✅ 步骤结果可折叠查看
|
||||
- ✅ 实时进度反馈
|
||||
- ✅ 异常处理和重试
|
||||
- ✅ 成本低廉(¥3-12/月)
|
||||
1197
docs/Community.md
Normal file
1197
docs/Community.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,16 +48,18 @@ npm start
|
||||
|
||||
### 3. 触发通知
|
||||
|
||||
**Mock 模式**(默认):
|
||||
- 等待 60 秒,会自动推送 1-2 条通知
|
||||
- 或在控制台执行:
|
||||
**测试通知**:
|
||||
- 使用调试 API 发送测试通知:
|
||||
```javascript
|
||||
import { mockSocketService } from './services/mockSocketService.js';
|
||||
mockSocketService.sendTestNotification();
|
||||
```
|
||||
// 方式1: 使用调试工具(推荐)
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '测试通知',
|
||||
body: '验证暗色模式下的通知样式'
|
||||
});
|
||||
|
||||
**Real 模式**:
|
||||
- 创建测试事件(运行后端测试脚本)
|
||||
// 方式2: 等待后端真实推送
|
||||
// 确保已连接后端,等待真实事件推送
|
||||
```
|
||||
|
||||
### 4. 验证效果
|
||||
|
||||
@@ -139,61 +141,46 @@ npm start
|
||||
|
||||
### 手动触发各类型通知
|
||||
|
||||
```javascript
|
||||
// 引入服务
|
||||
import { mockSocketService } from './services/mockSocketService.js';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from './constants/notificationTypes.js';
|
||||
> **注意**: Mock Socket 已移除,请使用调试工具或真实后端测试。
|
||||
|
||||
// 测试公告通知(蓝色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
```javascript
|
||||
// 使用调试工具测试不同类型的通知
|
||||
// 确保已开启调试模式:REACT_APP_ENABLE_DEBUG=true
|
||||
|
||||
// 测试公告通知
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '测试公告通知',
|
||||
content: '这是暗色模式下的蓝色通知',
|
||||
timestamp: Date.now(),
|
||||
body: '这是暗色模式下的蓝色通知',
|
||||
tag: 'test_announcement',
|
||||
autoClose: 0,
|
||||
});
|
||||
|
||||
// 测试股票上涨(红色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.URGENT,
|
||||
title: '测试股票上涨',
|
||||
content: '宁德时代 +5.2%',
|
||||
extra: { priceChange: '+5.2%' },
|
||||
timestamp: Date.now(),
|
||||
autoClose: 0,
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '🔴 测试股票上涨',
|
||||
body: '宁德时代 +5.2%',
|
||||
tag: 'test_stock_up',
|
||||
});
|
||||
|
||||
// 测试股票下跌(绿色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '测试股票下跌',
|
||||
content: '比亚迪 -3.8%',
|
||||
extra: { priceChange: '-3.8%' },
|
||||
timestamp: Date.now(),
|
||||
autoClose: 0,
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '🟢 测试股票下跌',
|
||||
body: '比亚迪 -3.8%',
|
||||
tag: 'test_stock_down',
|
||||
});
|
||||
|
||||
// 测试事件动向(橙色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '测试事件动向',
|
||||
content: '央行宣布降准',
|
||||
timestamp: Date.now(),
|
||||
autoClose: 0,
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '🟠 测试事件动向',
|
||||
body: '央行宣布降准',
|
||||
tag: 'test_event',
|
||||
});
|
||||
|
||||
// 测试分析报告(紫色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '测试分析报告',
|
||||
content: '医药行业深度报告',
|
||||
timestamp: Date.now(),
|
||||
autoClose: 0,
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '🟣 测试分析报告',
|
||||
body: '医药行业深度报告',
|
||||
tag: 'test_report',
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
309
docs/MCP_ARCHITECTURE.md
Normal file
309
docs/MCP_ARCHITECTURE.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# MCP 架构说明
|
||||
|
||||
## 🎯 MCP 是什么?
|
||||
|
||||
**MCP (Model Context Protocol)** 是一个**工具调用协议**,它的核心职责是:
|
||||
|
||||
1. ✅ **定义工具接口**:告诉 LLM 有哪些工具可用,每个工具需要什么参数
|
||||
2. ✅ **执行工具调用**:根据请求调用对应的后端 API
|
||||
3. ✅ **返回结构化数据**:将 API 结果返回给调用方
|
||||
|
||||
**MCP 不负责**:
|
||||
- ❌ 自然语言理解(NLU)
|
||||
- ❌ 意图识别
|
||||
- ❌ 结果总结
|
||||
- ❌ 对话管理
|
||||
|
||||
## 📊 当前架构
|
||||
|
||||
### 方案 1:简单关键词匹配(已实现)
|
||||
|
||||
```
|
||||
用户输入:"查询贵州茅台的股票信息"
|
||||
↓
|
||||
前端 ChatInterface (关键词匹配)
|
||||
↓
|
||||
MCP 工具层 (search_china_news)
|
||||
↓
|
||||
返回 JSON 数据
|
||||
↓
|
||||
前端显示原始数据
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- ✗ 只能识别简单关键词
|
||||
- ✗ 无法理解复杂意图
|
||||
- ✗ 返回的是原始 JSON,用户体验差
|
||||
|
||||
### 方案 2:集成 LLM(推荐)
|
||||
|
||||
```
|
||||
用户输入:"查询贵州茅台的股票信息"
|
||||
↓
|
||||
LLM (Claude/GPT-4/通义千问)
|
||||
↓ 理解意图:需要查询股票代码 600519 的基本信息
|
||||
↓ 选择工具:get_stock_basic_info
|
||||
↓ 提取参数:{"seccode": "600519"}
|
||||
MCP 工具层
|
||||
↓ 调用 API,获取数据
|
||||
返回结构化数据
|
||||
↓
|
||||
LLM 总结结果
|
||||
↓ "贵州茅台(600519)是中国知名的白酒生产企业,
|
||||
当前股价 1650.00 元,市值 2.07 万亿..."
|
||||
前端显示自然语言回复
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- ✓ 理解复杂意图
|
||||
- ✓ 自动选择合适的工具
|
||||
- ✓ 自然语言总结,用户体验好
|
||||
- ✓ 支持多轮对话
|
||||
|
||||
## 🔧 实现方案
|
||||
|
||||
### 选项 A:前端集成 LLM(快速实现)
|
||||
|
||||
**适用场景**:快速原型、小规模应用
|
||||
|
||||
**优点**:
|
||||
- 实现简单
|
||||
- 无需修改后端
|
||||
|
||||
**缺点**:
|
||||
- API Key 暴露在前端(安全风险)
|
||||
- 每个用户都消耗 API 额度
|
||||
- 无法统一管理和监控
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
1. 修改 `src/components/ChatBot/ChatInterface.js`:
|
||||
|
||||
```javascript
|
||||
import { llmService } from '../../services/llmService';
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
// ...
|
||||
|
||||
// 使用 LLM 服务替代简单的 mcpService.chat
|
||||
const response = await llmService.chat(inputValue, messages);
|
||||
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
2. 配置 API Key(在 `.env.local`):
|
||||
|
||||
```bash
|
||||
REACT_APP_OPENAI_API_KEY=sk-xxx...
|
||||
# 或者使用通义千问(更便宜)
|
||||
REACT_APP_DASHSCOPE_API_KEY=sk-xxx...
|
||||
```
|
||||
|
||||
### 选项 B:后端集成 LLM(生产推荐)⭐
|
||||
|
||||
**适用场景**:生产环境、需要安全和性能
|
||||
|
||||
**优点**:
|
||||
- ✓ API Key 安全(不暴露给前端)
|
||||
- ✓ 统一管理和监控
|
||||
- ✓ 可以做缓存优化
|
||||
- ✓ 可以做速率限制
|
||||
|
||||
**缺点**:
|
||||
- 需要修改后端
|
||||
- 增加服务器成本
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
#### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install openai
|
||||
```
|
||||
|
||||
#### 2. 修改 `mcp_server.py`,添加聊天端点
|
||||
|
||||
在文件末尾添加:
|
||||
|
||||
```python
|
||||
from mcp_chat_endpoint import MCPChatAssistant, ChatRequest, ChatResponse
|
||||
|
||||
# 创建聊天助手实例
|
||||
chat_assistant = MCPChatAssistant(provider="qwen") # 推荐使用通义千问
|
||||
|
||||
@app.post("/chat", response_model=ChatResponse)
|
||||
async def chat_endpoint(request: ChatRequest):
|
||||
"""智能对话端点 - 使用LLM理解意图并调用工具"""
|
||||
logger.info(f"Chat request: {request.message}")
|
||||
|
||||
# 获取可用工具列表
|
||||
tools = [tool.dict() for tool in TOOLS]
|
||||
|
||||
# 调用聊天助手
|
||||
response = await chat_assistant.chat(
|
||||
user_message=request.message,
|
||||
conversation_history=request.conversation_history,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
#### 3. 配置环境变量
|
||||
|
||||
在服务器上设置:
|
||||
|
||||
```bash
|
||||
# 方式1:使用通义千问(推荐,价格便宜)
|
||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
||||
|
||||
# 方式2:使用 OpenAI
|
||||
export OPENAI_API_KEY="sk-xxx..."
|
||||
|
||||
# 方式3:使用 DeepSeek(最便宜)
|
||||
export DEEPSEEK_API_KEY="sk-xxx..."
|
||||
```
|
||||
|
||||
#### 4. 修改前端 `mcpService.js`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 智能对话 - 使用后端LLM处理
|
||||
*/
|
||||
async chat(userMessage, conversationHistory = []) {
|
||||
try {
|
||||
const response = await this.client.post('/chat', {
|
||||
message: userMessage,
|
||||
conversation_history: conversationHistory,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '对话处理失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 修改前端 `ChatInterface.js`
|
||||
|
||||
```javascript
|
||||
const handleSendMessage = async () => {
|
||||
// ...
|
||||
|
||||
try {
|
||||
// 调用后端聊天API
|
||||
const response = await mcpService.chat(inputValue, messages);
|
||||
|
||||
if (response.success) {
|
||||
const botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: response.data.message, // LLM总结的自然语言
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
toolUsed: response.data.tool_used, // 可选:显示使用了哪个工具
|
||||
rawData: response.data.raw_data, // 可选:原始数据(折叠显示)
|
||||
};
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
}
|
||||
} catch (error) {
|
||||
// ...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 💰 LLM 选择和成本
|
||||
|
||||
### 推荐:通义千问(阿里云)
|
||||
|
||||
**优点**:
|
||||
- 价格便宜(1000次对话约 ¥1-2)
|
||||
- 中文理解能力强
|
||||
- 国内访问稳定
|
||||
|
||||
**价格**:
|
||||
- qwen-plus: ¥0.004/1000 tokens(约 ¥0.001/次对话)
|
||||
- qwen-turbo: ¥0.002/1000 tokens(更便宜)
|
||||
|
||||
**获取 API Key**:
|
||||
1. 访问 https://dashscope.console.aliyun.com/
|
||||
2. 创建 API Key
|
||||
3. 设置环境变量 `DASHSCOPE_API_KEY`
|
||||
|
||||
### 其他选择
|
||||
|
||||
| 提供商 | 模型 | 价格 | 优点 | 缺点 |
|
||||
|--------|------|------|------|------|
|
||||
| **通义千问** | qwen-plus | ¥0.001/次 | 便宜、中文好 | - |
|
||||
| **DeepSeek** | deepseek-chat | ¥0.0005/次 | 最便宜 | 新公司 |
|
||||
| **OpenAI** | gpt-4o-mini | $0.15/1M tokens | 能力强 | 贵、需翻墙 |
|
||||
| **Claude** | claude-3-haiku | $0.25/1M tokens | 理解力强 | 贵、需翻墙 |
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 1. 后端部署
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install openai
|
||||
|
||||
# 设置 API Key
|
||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart mcp-server
|
||||
|
||||
# 测试聊天端点
|
||||
curl -X POST https://valuefrontier.cn/mcp/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "查询贵州茅台的股票信息"}'
|
||||
```
|
||||
|
||||
### 2. 前端部署
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
npm run build
|
||||
|
||||
# 部署
|
||||
scp -r build/* user@server:/var/www/valuefrontier.cn/
|
||||
```
|
||||
|
||||
### 3. 验证
|
||||
|
||||
访问 https://valuefrontier.cn/agent-chat,测试对话:
|
||||
|
||||
**测试用例**:
|
||||
1. "查询贵州茅台的股票信息" → 应返回自然语言总结
|
||||
2. "今日涨停的股票有哪些" → 应返回涨停股票列表并总结
|
||||
3. "新能源概念板块表现如何" → 应搜索概念并分析
|
||||
|
||||
## 📊 对比总结
|
||||
|
||||
| 特性 | 简单匹配 | 前端LLM | 后端LLM ⭐ |
|
||||
|------|---------|---------|-----------|
|
||||
| 实现难度 | 简单 | 中等 | 中等 |
|
||||
| 用户体验 | 差 | 好 | 好 |
|
||||
| 安全性 | 高 | 低 | 高 |
|
||||
| 成本 | 无 | 用户承担 | 服务器承担 |
|
||||
| 可维护性 | 差 | 中 | 好 |
|
||||
| **推荐指数** | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
## 🎯 最终推荐
|
||||
|
||||
**生产环境:后端集成 LLM (方案 B)**
|
||||
- 使用通义千问(qwen-plus)
|
||||
- 成本低(约 ¥50/月,10000次对话)
|
||||
- 安全可靠
|
||||
|
||||
**快速原型:前端集成 LLM (方案 A)**
|
||||
- 适合演示
|
||||
- 快速验证可行性
|
||||
- 后续再迁移到后端
|
||||
@@ -330,13 +330,14 @@ if (Notification.permission === 'granted') {
|
||||
|
||||
### 关键文件
|
||||
|
||||
- `src/services/mockSocketService.js` - Mock Socket 服务
|
||||
- `src/services/socketService.js` - 真实 Socket.IO 服务
|
||||
- `src/services/socket/index.js` - 统一导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文(含适配器)
|
||||
- `src/services/socketService.js` - Socket.IO 服务
|
||||
- `src/services/socket/index.js` - Socket 服务导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文
|
||||
- `src/hooks/useEventNotifications.js` - React Hook
|
||||
- `src/views/Community/components/EventList.js` - 事件列表集成
|
||||
|
||||
> **注意**: `mockSocketService.js` 已移除(2025-01-10),现仅使用真实 Socket 连接。
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1770
docs/test-cases/notification-tests.md
Normal file
1770
docs/test-cases/notification-tests.md
Normal file
File diff suppressed because it is too large
Load Diff
108
mcp_config.py
Normal file
108
mcp_config.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
MCP服务器配置文件
|
||||
集中管理所有配置项
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
from pydantic import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 服务器配置
|
||||
SERVER_HOST: str = "0.0.0.0"
|
||||
SERVER_PORT: int = 8900
|
||||
DEBUG: bool = True
|
||||
|
||||
# 后端API服务端点
|
||||
NEWS_API_URL: str = "http://222.128.1.157:21891"
|
||||
ROADSHOW_API_URL: str = "http://222.128.1.157:19800"
|
||||
CONCEPT_API_URL: str = "http://222.128.1.157:16801"
|
||||
STOCK_ANALYSIS_API_URL: str = "http://222.128.1.157:8811"
|
||||
|
||||
# HTTP客户端配置
|
||||
HTTP_TIMEOUT: float = 60.0
|
||||
HTTP_MAX_RETRIES: int = 3
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
# CORS配置
|
||||
CORS_ORIGINS: list = ["*"]
|
||||
CORS_CREDENTIALS: bool = True
|
||||
CORS_METHODS: list = ["*"]
|
||||
CORS_HEADERS: list = ["*"]
|
||||
|
||||
# LLM配置(如果需要集成)
|
||||
LLM_PROVIDER: str = "openai" # openai, anthropic, etc.
|
||||
LLM_API_KEY: str = ""
|
||||
LLM_MODEL: str = "gpt-4"
|
||||
LLM_BASE_URL: str = ""
|
||||
|
||||
# 速率限制
|
||||
RATE_LIMIT_ENABLED: bool = False
|
||||
RATE_LIMIT_PER_MINUTE: int = 60
|
||||
|
||||
# 缓存配置
|
||||
CACHE_ENABLED: bool = True
|
||||
CACHE_TTL: int = 300 # 秒
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# 全局设置实例
|
||||
settings = Settings()
|
||||
|
||||
|
||||
# 工具类别映射(用于组织和展示)
|
||||
TOOL_CATEGORIES: Dict[str, list] = {
|
||||
"新闻搜索": [
|
||||
"search_news",
|
||||
"search_china_news",
|
||||
"search_medical_news"
|
||||
],
|
||||
"公司研究": [
|
||||
"search_roadshows",
|
||||
"search_research_reports"
|
||||
],
|
||||
"概念板块": [
|
||||
"search_concepts",
|
||||
"get_concept_details",
|
||||
"get_stock_concepts",
|
||||
"get_concept_statistics"
|
||||
],
|
||||
"股票分析": [
|
||||
"search_limit_up_stocks",
|
||||
"get_daily_stock_analysis"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# 工具优先级(用于LLM选择工具时的提示)
|
||||
TOOL_PRIORITIES: Dict[str, int] = {
|
||||
"search_china_news": 10, # 最常用
|
||||
"search_concepts": 9,
|
||||
"search_limit_up_stocks": 8,
|
||||
"search_research_reports": 8,
|
||||
"get_stock_concepts": 7,
|
||||
"search_news": 6,
|
||||
"get_daily_stock_analysis": 5,
|
||||
"get_concept_statistics": 5,
|
||||
"search_medical_news": 4,
|
||||
"search_roadshows": 4,
|
||||
"get_concept_details": 3,
|
||||
}
|
||||
|
||||
|
||||
# 默认参数配置
|
||||
DEFAULT_PARAMS = {
|
||||
"top_k": 20,
|
||||
"page_size": 20,
|
||||
"size": 10,
|
||||
"sort_by": "change_pct",
|
||||
"mode": "hybrid",
|
||||
"exact_match": False,
|
||||
}
|
||||
783
mcp_database.py
Normal file
783
mcp_database.py
Normal file
@@ -0,0 +1,783 @@
|
||||
"""
|
||||
MySQL数据库查询模块
|
||||
提供股票财务数据查询功能
|
||||
"""
|
||||
|
||||
import aiomysql
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MySQL连接配置
|
||||
MYSQL_CONFIG = {
|
||||
'host': '222.128.1.157',
|
||||
'port': 33060,
|
||||
'user': 'root',
|
||||
'password': 'Zzl5588161!',
|
||||
'db': 'stock',
|
||||
'charset': 'utf8mb4',
|
||||
'autocommit': True
|
||||
}
|
||||
|
||||
# 全局连接池
|
||||
_pool = None
|
||||
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON编码器,处理datetime和Decimal类型"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
async def get_pool():
|
||||
"""获取MySQL连接池"""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = await aiomysql.create_pool(
|
||||
host=MYSQL_CONFIG['host'],
|
||||
port=MYSQL_CONFIG['port'],
|
||||
user=MYSQL_CONFIG['user'],
|
||||
password=MYSQL_CONFIG['password'],
|
||||
db=MYSQL_CONFIG['db'],
|
||||
charset=MYSQL_CONFIG['charset'],
|
||||
autocommit=MYSQL_CONFIG['autocommit'],
|
||||
minsize=1,
|
||||
maxsize=10
|
||||
)
|
||||
logger.info("MySQL connection pool created")
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
"""关闭MySQL连接池"""
|
||||
global _pool
|
||||
if _pool:
|
||||
_pool.close()
|
||||
await _pool.wait_closed()
|
||||
_pool = None
|
||||
logger.info("MySQL connection pool closed")
|
||||
|
||||
|
||||
def convert_row(row: Dict) -> Dict:
|
||||
"""转换数据库行,处理特殊类型"""
|
||||
if not row:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
for key, value in row.items():
|
||||
if isinstance(value, Decimal):
|
||||
result[key] = float(value)
|
||||
elif isinstance(value, (datetime, date)):
|
||||
result[key] = value.isoformat()
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
async def get_stock_basic_info(seccode: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取股票基本信息
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
|
||||
Returns:
|
||||
股票基本信息字典
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, ORGNAME,
|
||||
F001V as english_name,
|
||||
F003V as legal_representative,
|
||||
F004V as registered_address,
|
||||
F005V as office_address,
|
||||
F010D as establishment_date,
|
||||
F011V as website,
|
||||
F012V as email,
|
||||
F013V as phone,
|
||||
F015V as main_business,
|
||||
F016V as business_scope,
|
||||
F017V as company_profile,
|
||||
F030V as industry_level1,
|
||||
F032V as industry_level2,
|
||||
F034V as sw_industry_level1,
|
||||
F036V as sw_industry_level2,
|
||||
F026V as province,
|
||||
F028V as city,
|
||||
F041V as chairman,
|
||||
F042V as general_manager,
|
||||
UPDATE_DATE as update_date
|
||||
FROM ea_baseinfo
|
||||
WHERE SECCODE = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
await cursor.execute(query, (seccode,))
|
||||
result = await cursor.fetchone()
|
||||
|
||||
if result:
|
||||
return convert_row(result)
|
||||
return None
|
||||
|
||||
|
||||
async def get_stock_financial_index(
|
||||
seccode: str,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取股票财务指标
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
start_date: 开始日期 YYYY-MM-DD
|
||||
end_date: 结束日期 YYYY-MM-DD
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
财务指标列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 构建查询
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, ENDDATE, STARTDATE,
|
||||
F069D as report_year,
|
||||
F003N as eps, -- 每股收益
|
||||
F004N as basic_eps,
|
||||
F008N as bps, -- 每股净资产
|
||||
F014N as roe, -- 净资产收益率
|
||||
F016N as roa, -- 总资产报酬率
|
||||
F017N as net_profit_margin, -- 净利润率
|
||||
F022N as receivable_turnover, -- 应收账款周转率
|
||||
F023N as inventory_turnover, -- 存货周转率
|
||||
F025N as total_asset_turnover, -- 总资产周转率
|
||||
F041N as debt_ratio, -- 资产负债率
|
||||
F042N as current_ratio, -- 流动比率
|
||||
F043N as quick_ratio, -- 速动比率
|
||||
F052N as revenue_growth, -- 营业收入增长率
|
||||
F053N as profit_growth, -- 净利润增长率
|
||||
F089N as revenue, -- 营业收入
|
||||
F090N as operating_cost, -- 营业成本
|
||||
F101N as net_profit, -- 净利润
|
||||
F102N as net_profit_parent -- 归母净利润
|
||||
FROM ea_financialindex
|
||||
WHERE SECCODE = %s
|
||||
"""
|
||||
|
||||
params = [seccode]
|
||||
|
||||
if start_date:
|
||||
query += " AND ENDDATE >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND ENDDATE <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_stock_trade_data(
|
||||
seccode: str,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取股票交易数据
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
start_date: 开始日期 YYYY-MM-DD
|
||||
end_date: 结束日期 YYYY-MM-DD
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
交易数据列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, TRADEDATE,
|
||||
F002N as prev_close, -- 昨日收盘价
|
||||
F003N as open_price, -- 开盘价
|
||||
F005N as high_price, -- 最高价
|
||||
F006N as low_price, -- 最低价
|
||||
F007N as close_price, -- 收盘价
|
||||
F004N as volume, -- 成交量
|
||||
F011N as turnover, -- 成交金额
|
||||
F009N as change_amount, -- 涨跌额
|
||||
F010N as change_pct, -- 涨跌幅
|
||||
F012N as turnover_rate, -- 换手率
|
||||
F013N as amplitude, -- 振幅
|
||||
F026N as pe_ratio, -- 市盈率
|
||||
F020N as total_shares, -- 总股本
|
||||
F021N as circulating_shares -- 流通股本
|
||||
FROM ea_trade
|
||||
WHERE SECCODE = %s
|
||||
"""
|
||||
|
||||
params = [seccode]
|
||||
|
||||
if start_date:
|
||||
query += " AND TRADEDATE >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND TRADEDATE <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY TRADEDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_stock_balance_sheet(
|
||||
seccode: str,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 8
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取资产负债表数据
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
资产负债表数据列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, ENDDATE,
|
||||
F001D as report_year,
|
||||
F006N as cash, -- 货币资金
|
||||
F009N as receivables, -- 应收账款
|
||||
F015N as inventory, -- 存货
|
||||
F019N as current_assets, -- 流动资产合计
|
||||
F023N as long_term_investment, -- 长期股权投资
|
||||
F025N as fixed_assets, -- 固定资产
|
||||
F037N as noncurrent_assets, -- 非流动资产合计
|
||||
F038N as total_assets, -- 资产总计
|
||||
F039N as short_term_loan, -- 短期借款
|
||||
F042N as payables, -- 应付账款
|
||||
F052N as current_liabilities, -- 流动负债合计
|
||||
F053N as long_term_loan, -- 长期借款
|
||||
F060N as noncurrent_liabilities, -- 非流动负债合计
|
||||
F061N as total_liabilities, -- 负债合计
|
||||
F062N as share_capital, -- 股本
|
||||
F063N as capital_reserve, -- 资本公积
|
||||
F065N as retained_earnings, -- 未分配利润
|
||||
F070N as total_equity -- 所有者权益合计
|
||||
FROM ea_asset
|
||||
WHERE SECCODE = %s
|
||||
"""
|
||||
|
||||
params = [seccode]
|
||||
|
||||
if start_date:
|
||||
query += " AND ENDDATE >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND ENDDATE <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_stock_cashflow(
|
||||
seccode: str,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 8
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取现金流量表数据
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
现金流量表数据列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, ENDDATE, STARTDATE,
|
||||
F001D as report_year,
|
||||
F009N as operating_cash_inflow, -- 经营活动现金流入
|
||||
F014N as operating_cash_outflow, -- 经营活动现金流出
|
||||
F015N as net_operating_cashflow, -- 经营活动现金流量净额
|
||||
F021N as investing_cash_inflow, -- 投资活动现金流入
|
||||
F026N as investing_cash_outflow, -- 投资活动现金流出
|
||||
F027N as net_investing_cashflow, -- 投资活动现金流量净额
|
||||
F031N as financing_cash_inflow, -- 筹资活动现金流入
|
||||
F035N as financing_cash_outflow, -- 筹资活动现金流出
|
||||
F036N as net_financing_cashflow, -- 筹资活动现金流量净额
|
||||
F039N as net_cash_increase, -- 现金及现金等价物净增加额
|
||||
F044N as net_profit, -- 净利润
|
||||
F046N as depreciation, -- 固定资产折旧
|
||||
F060N as net_operating_cashflow_adjusted -- 经营活动现金流量净额(补充)
|
||||
FROM ea_cashflow
|
||||
WHERE SECCODE = %s
|
||||
"""
|
||||
|
||||
params = [seccode]
|
||||
|
||||
if start_date:
|
||||
query += " AND ENDDATE >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND ENDDATE <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def search_stocks_by_criteria(
|
||||
industry: Optional[str] = None,
|
||||
province: Optional[str] = None,
|
||||
min_market_cap: Optional[float] = None,
|
||||
max_market_cap: Optional[float] = None,
|
||||
limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
按条件搜索股票
|
||||
|
||||
Args:
|
||||
industry: 行业名称
|
||||
province: 省份
|
||||
min_market_cap: 最小市值(亿元)
|
||||
max_market_cap: 最大市值(亿元)
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
股票列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT DISTINCT
|
||||
b.SECCODE,
|
||||
b.SECNAME,
|
||||
b.F030V as industry_level1,
|
||||
b.F032V as industry_level2,
|
||||
b.F034V as sw_industry_level1,
|
||||
b.F026V as province,
|
||||
b.F028V as city,
|
||||
b.F015V as main_business,
|
||||
t.F007N as latest_price,
|
||||
t.F010N as change_pct,
|
||||
t.F026N as pe_ratio,
|
||||
t.TRADEDATE as latest_trade_date
|
||||
FROM ea_baseinfo b
|
||||
LEFT JOIN (
|
||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
||||
FROM ea_trade
|
||||
GROUP BY SECCODE
|
||||
) latest ON b.SECCODE = latest.SECCODE
|
||||
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
|
||||
AND t.TRADEDATE = latest.max_date
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
if industry:
|
||||
query += " AND (b.F030V LIKE %s OR b.F032V LIKE %s OR b.F034V LIKE %s)"
|
||||
pattern = f"%{industry}%"
|
||||
params.extend([pattern, pattern, pattern])
|
||||
|
||||
if province:
|
||||
query += " AND b.F026V = %s"
|
||||
params.append(province)
|
||||
|
||||
if min_market_cap or max_market_cap:
|
||||
# 市值 = 最新价 * 总股本 / 100000000(转换为亿元)
|
||||
if min_market_cap:
|
||||
query += " AND (t.F007N * t.F020N / 100000000) >= %s"
|
||||
params.append(min_market_cap)
|
||||
|
||||
if max_market_cap:
|
||||
query += " AND (t.F007N * t.F020N / 100000000) <= %s"
|
||||
params.append(max_market_cap)
|
||||
|
||||
query += " ORDER BY t.TRADEDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_stock_comparison(
|
||||
seccodes: List[str],
|
||||
metric: str = "financial"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
股票对比分析
|
||||
|
||||
Args:
|
||||
seccodes: 股票代码列表
|
||||
metric: 对比指标类型 (financial/trade)
|
||||
|
||||
Returns:
|
||||
对比数据
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
if not seccodes or len(seccodes) < 2:
|
||||
return {"error": "至少需要2个股票代码进行对比"}
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
placeholders = ','.join(['%s'] * len(seccodes))
|
||||
|
||||
if metric == "financial":
|
||||
# 对比最新财务指标
|
||||
query = f"""
|
||||
SELECT
|
||||
f.SECCODE, f.SECNAME, f.ENDDATE,
|
||||
f.F003N as eps,
|
||||
f.F008N as bps,
|
||||
f.F014N as roe,
|
||||
f.F017N as net_profit_margin,
|
||||
f.F041N as debt_ratio,
|
||||
f.F052N as revenue_growth,
|
||||
f.F053N as profit_growth,
|
||||
f.F089N as revenue,
|
||||
f.F101N as net_profit
|
||||
FROM ea_financialindex f
|
||||
INNER JOIN (
|
||||
SELECT SECCODE, MAX(ENDDATE) as max_date
|
||||
FROM ea_financialindex
|
||||
WHERE SECCODE IN ({placeholders})
|
||||
GROUP BY SECCODE
|
||||
) latest ON f.SECCODE = latest.SECCODE
|
||||
AND f.ENDDATE = latest.max_date
|
||||
"""
|
||||
else: # trade
|
||||
# 对比最新交易数据
|
||||
query = f"""
|
||||
SELECT
|
||||
t.SECCODE, t.SECNAME, t.TRADEDATE,
|
||||
t.F007N as close_price,
|
||||
t.F010N as change_pct,
|
||||
t.F012N as turnover_rate,
|
||||
t.F026N as pe_ratio,
|
||||
t.F020N as total_shares,
|
||||
t.F021N as circulating_shares
|
||||
FROM ea_trade t
|
||||
INNER JOIN (
|
||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ({placeholders})
|
||||
GROUP BY SECCODE
|
||||
) latest ON t.SECCODE = latest.SECCODE
|
||||
AND t.TRADEDATE = latest.max_date
|
||||
"""
|
||||
|
||||
await cursor.execute(query, seccodes)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return {
|
||||
"comparison_type": metric,
|
||||
"stocks": [convert_row(row) for row in results]
|
||||
}
|
||||
|
||||
|
||||
async def get_user_favorite_stocks(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取用户自选股列表
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
自选股列表(包含最新行情数据)
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 查询用户自选股(假设有 user_favorites 表)
|
||||
# 如果没有此表,可以根据实际情况调整
|
||||
query = """
|
||||
SELECT
|
||||
f.user_id,
|
||||
f.stock_code,
|
||||
b.SECNAME as stock_name,
|
||||
b.F030V as industry,
|
||||
t.F007N as current_price,
|
||||
t.F010N as change_pct,
|
||||
t.F012N as turnover_rate,
|
||||
t.F026N as pe_ratio,
|
||||
t.TRADEDATE as latest_trade_date,
|
||||
f.created_at as favorite_time
|
||||
FROM user_favorites f
|
||||
INNER JOIN ea_baseinfo b ON f.stock_code = b.SECCODE
|
||||
LEFT JOIN (
|
||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
||||
FROM ea_trade
|
||||
GROUP BY SECCODE
|
||||
) latest ON b.SECCODE = latest.SECCODE
|
||||
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
|
||||
AND t.TRADEDATE = latest.max_date
|
||||
WHERE f.user_id = %s AND f.is_deleted = 0
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
await cursor.execute(query, [user_id, limit])
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_user_favorite_events(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取用户自选事件列表
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
自选事件列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 查询用户自选事件(假设有 user_event_favorites 表)
|
||||
query = """
|
||||
SELECT
|
||||
f.user_id,
|
||||
f.event_id,
|
||||
e.title,
|
||||
e.description,
|
||||
e.event_date,
|
||||
e.importance,
|
||||
e.related_stocks,
|
||||
e.category,
|
||||
f.created_at as favorite_time
|
||||
FROM user_event_favorites f
|
||||
INNER JOIN events e ON f.event_id = e.id
|
||||
WHERE f.user_id = %s AND f.is_deleted = 0
|
||||
ORDER BY e.event_date DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
await cursor.execute(query, [user_id, limit])
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def add_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
添加自选股
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 检查是否已存在
|
||||
check_query = """
|
||||
SELECT id, is_deleted
|
||||
FROM user_favorites
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
"""
|
||||
await cursor.execute(check_query, [user_id, stock_code])
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
if existing['is_deleted'] == 1:
|
||||
# 恢复已删除的记录
|
||||
update_query = """
|
||||
UPDATE user_favorites
|
||||
SET is_deleted = 0, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
await cursor.execute(update_query, [existing['id']])
|
||||
return {"success": True, "message": "已恢复自选股"}
|
||||
else:
|
||||
return {"success": False, "message": "该股票已在自选中"}
|
||||
|
||||
# 插入新记录
|
||||
insert_query = """
|
||||
INSERT INTO user_favorites (user_id, stock_code, created_at, updated_at, is_deleted)
|
||||
VALUES (%s, %s, NOW(), NOW(), 0)
|
||||
"""
|
||||
await cursor.execute(insert_query, [user_id, stock_code])
|
||||
return {"success": True, "message": "添加自选股成功"}
|
||||
|
||||
|
||||
async def remove_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
删除自选股
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
UPDATE user_favorites
|
||||
SET is_deleted = 1, updated_at = NOW()
|
||||
WHERE user_id = %s AND stock_code = %s AND is_deleted = 0
|
||||
"""
|
||||
result = await cursor.execute(query, [user_id, stock_code])
|
||||
|
||||
if result > 0:
|
||||
return {"success": True, "message": "删除自选股成功"}
|
||||
else:
|
||||
return {"success": False, "message": "未找到该自选股"}
|
||||
|
||||
|
||||
async def add_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
添加自选事件
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
event_id: 事件ID
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 检查是否已存在
|
||||
check_query = """
|
||||
SELECT id, is_deleted
|
||||
FROM user_event_favorites
|
||||
WHERE user_id = %s AND event_id = %s
|
||||
"""
|
||||
await cursor.execute(check_query, [user_id, event_id])
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
if existing['is_deleted'] == 1:
|
||||
# 恢复已删除的记录
|
||||
update_query = """
|
||||
UPDATE user_event_favorites
|
||||
SET is_deleted = 0, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
await cursor.execute(update_query, [existing['id']])
|
||||
return {"success": True, "message": "已恢复自选事件"}
|
||||
else:
|
||||
return {"success": False, "message": "该事件已在自选中"}
|
||||
|
||||
# 插入新记录
|
||||
insert_query = """
|
||||
INSERT INTO user_event_favorites (user_id, event_id, created_at, updated_at, is_deleted)
|
||||
VALUES (%s, %s, NOW(), NOW(), 0)
|
||||
"""
|
||||
await cursor.execute(insert_query, [user_id, event_id])
|
||||
return {"success": True, "message": "添加自选事件成功"}
|
||||
|
||||
|
||||
async def remove_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
删除自选事件
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
event_id: 事件ID
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
UPDATE user_event_favorites
|
||||
SET is_deleted = 1, updated_at = NOW()
|
||||
WHERE user_id = %s AND event_id = %s AND is_deleted = 0
|
||||
"""
|
||||
result = await cursor.execute(query, [user_id, event_id])
|
||||
|
||||
if result > 0:
|
||||
return {"success": True, "message": "删除自选事件成功"}
|
||||
else:
|
||||
return {"success": False, "message": "未找到该自选事件"}
|
||||
320
mcp_elasticsearch.py
Normal file
320
mcp_elasticsearch.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Elasticsearch 连接和工具模块
|
||||
用于聊天记录存储和向量搜索
|
||||
"""
|
||||
|
||||
from elasticsearch import Elasticsearch, helpers
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
import logging
|
||||
import json
|
||||
import openai
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
# ES 配置
|
||||
ES_CONFIG = {
|
||||
"host": "http://222.128.1.157:19200",
|
||||
"index_chat_history": "agent_chat_history", # 聊天记录索引
|
||||
}
|
||||
|
||||
# Embedding 配置
|
||||
EMBEDDING_CONFIG = {
|
||||
"api_key": "dummy",
|
||||
"base_url": "http://222.128.1.157:18008/v1",
|
||||
"model": "qwen3-embedding-8b",
|
||||
"dims": 4096, # 向量维度
|
||||
}
|
||||
|
||||
# ==================== ES 客户端 ====================
|
||||
|
||||
class ESClient:
|
||||
"""Elasticsearch 客户端封装"""
|
||||
|
||||
def __init__(self):
|
||||
self.es = Elasticsearch([ES_CONFIG["host"]], request_timeout=60)
|
||||
self.chat_index = ES_CONFIG["index_chat_history"]
|
||||
|
||||
# 初始化 OpenAI 客户端用于 embedding
|
||||
self.embedding_client = openai.OpenAI(
|
||||
api_key=EMBEDDING_CONFIG["api_key"],
|
||||
base_url=EMBEDDING_CONFIG["base_url"],
|
||||
)
|
||||
self.embedding_model = EMBEDDING_CONFIG["model"]
|
||||
|
||||
# 初始化索引
|
||||
self.create_chat_history_index()
|
||||
|
||||
def create_chat_history_index(self):
|
||||
"""创建聊天记录索引"""
|
||||
if self.es.indices.exists(index=self.chat_index):
|
||||
logger.info(f"索引 {self.chat_index} 已存在")
|
||||
return
|
||||
|
||||
mappings = {
|
||||
"properties": {
|
||||
"session_id": {"type": "keyword"}, # 会话ID
|
||||
"user_id": {"type": "keyword"}, # 用户ID
|
||||
"user_nickname": {"type": "text"}, # 用户昵称
|
||||
"user_avatar": {"type": "keyword"}, # 用户头像URL
|
||||
"message_type": {"type": "keyword"}, # user / assistant
|
||||
"message": {"type": "text"}, # 消息内容
|
||||
"message_embedding": { # 消息向量
|
||||
"type": "dense_vector",
|
||||
"dims": EMBEDDING_CONFIG["dims"],
|
||||
"index": True,
|
||||
"similarity": "cosine"
|
||||
},
|
||||
"plan": {"type": "text"}, # 执行计划(仅 assistant)
|
||||
"steps": {"type": "text"}, # 执行步骤(仅 assistant)
|
||||
"timestamp": {"type": "date"}, # 时间戳
|
||||
"created_at": {"type": "date"}, # 创建时间
|
||||
}
|
||||
}
|
||||
|
||||
self.es.indices.create(index=self.chat_index, body={"mappings": mappings})
|
||||
logger.info(f"创建索引: {self.chat_index}")
|
||||
|
||||
def generate_embedding(self, text: str) -> List[float]:
|
||||
"""生成文本向量"""
|
||||
try:
|
||||
if not text or len(text.strip()) == 0:
|
||||
return []
|
||||
|
||||
# 截断过长文本
|
||||
text = text[:16000] if len(text) > 16000 else text
|
||||
|
||||
response = self.embedding_client.embeddings.create(
|
||||
model=self.embedding_model,
|
||||
input=[text]
|
||||
)
|
||||
return response.data[0].embedding
|
||||
except Exception as e:
|
||||
logger.error(f"Embedding 生成失败: {e}")
|
||||
return []
|
||||
|
||||
def save_chat_message(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
user_nickname: str,
|
||||
user_avatar: str,
|
||||
message_type: str, # "user" or "assistant"
|
||||
message: str,
|
||||
plan: Optional[str] = None,
|
||||
steps: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
保存聊天消息
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
user_id: 用户ID
|
||||
user_nickname: 用户昵称
|
||||
user_avatar: 用户头像URL
|
||||
message_type: 消息类型 (user/assistant)
|
||||
message: 消息内容
|
||||
plan: 执行计划(可选)
|
||||
steps: 执行步骤(可选)
|
||||
|
||||
Returns:
|
||||
文档ID
|
||||
"""
|
||||
try:
|
||||
# 生成向量
|
||||
embedding = self.generate_embedding(message)
|
||||
|
||||
doc = {
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"user_nickname": user_nickname,
|
||||
"user_avatar": user_avatar,
|
||||
"message_type": message_type,
|
||||
"message": message,
|
||||
"message_embedding": embedding if embedding else None,
|
||||
"plan": plan,
|
||||
"steps": steps,
|
||||
"timestamp": datetime.now(),
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
|
||||
result = self.es.index(index=self.chat_index, body=doc)
|
||||
logger.info(f"保存聊天记录: {result['_id']}")
|
||||
return result["_id"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存聊天记录失败: {e}")
|
||||
raise
|
||||
|
||||
def get_chat_sessions(self, user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取用户的聊天会话列表
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
limit: 返回数量
|
||||
|
||||
Returns:
|
||||
会话列表,每个会话包含:session_id, last_message, last_timestamp
|
||||
"""
|
||||
try:
|
||||
# 聚合查询:按 session_id 分组,获取每个会话的最后一条消息
|
||||
query = {
|
||||
"query": {
|
||||
"term": {"user_id": user_id}
|
||||
},
|
||||
"aggs": {
|
||||
"sessions": {
|
||||
"terms": {
|
||||
"field": "session_id",
|
||||
"size": limit,
|
||||
"order": {"last_message": "desc"}
|
||||
},
|
||||
"aggs": {
|
||||
"last_message": {
|
||||
"max": {"field": "timestamp"}
|
||||
},
|
||||
"last_message_content": {
|
||||
"top_hits": {
|
||||
"size": 1,
|
||||
"sort": [{"timestamp": {"order": "desc"}}],
|
||||
"_source": ["message", "timestamp", "message_type"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": 0
|
||||
}
|
||||
|
||||
result = self.es.search(index=self.chat_index, body=query)
|
||||
|
||||
sessions = []
|
||||
for bucket in result["aggregations"]["sessions"]["buckets"]:
|
||||
session_data = bucket["last_message_content"]["hits"]["hits"][0]["_source"]
|
||||
sessions.append({
|
||||
"session_id": bucket["key"],
|
||||
"last_message": session_data["message"],
|
||||
"last_timestamp": session_data["timestamp"],
|
||||
"message_count": bucket["doc_count"],
|
||||
})
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取会话列表失败: {e}")
|
||||
return []
|
||||
|
||||
def get_chat_history(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取指定会话的聊天历史
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
limit: 返回数量
|
||||
|
||||
Returns:
|
||||
聊天记录列表
|
||||
"""
|
||||
try:
|
||||
query = {
|
||||
"query": {
|
||||
"term": {"session_id": session_id}
|
||||
},
|
||||
"sort": [{"timestamp": {"order": "asc"}}],
|
||||
"size": limit
|
||||
}
|
||||
|
||||
result = self.es.search(index=self.chat_index, body=query)
|
||||
|
||||
messages = []
|
||||
for hit in result["hits"]["hits"]:
|
||||
doc = hit["_source"]
|
||||
messages.append({
|
||||
"message_type": doc["message_type"],
|
||||
"message": doc["message"],
|
||||
"plan": doc.get("plan"),
|
||||
"steps": doc.get("steps"),
|
||||
"timestamp": doc["timestamp"],
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取聊天历史失败: {e}")
|
||||
return []
|
||||
|
||||
def search_chat_history(
|
||||
self,
|
||||
user_id: str,
|
||||
query_text: str,
|
||||
top_k: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
向量搜索聊天历史
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
query_text: 查询文本
|
||||
top_k: 返回数量
|
||||
|
||||
Returns:
|
||||
相关聊天记录列表
|
||||
"""
|
||||
try:
|
||||
# 生成查询向量
|
||||
query_embedding = self.generate_embedding(query_text)
|
||||
if not query_embedding:
|
||||
return []
|
||||
|
||||
# 向量搜索
|
||||
query = {
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [
|
||||
{"term": {"user_id": user_id}},
|
||||
{
|
||||
"script_score": {
|
||||
"query": {"match_all": {}},
|
||||
"script": {
|
||||
"source": "cosineSimilarity(params.query_vector, 'message_embedding') + 1.0",
|
||||
"params": {"query_vector": query_embedding}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"size": top_k
|
||||
}
|
||||
|
||||
result = self.es.search(index=self.chat_index, body=query)
|
||||
|
||||
messages = []
|
||||
for hit in result["hits"]["hits"]:
|
||||
doc = hit["_source"]
|
||||
messages.append({
|
||||
"session_id": doc["session_id"],
|
||||
"message_type": doc["message_type"],
|
||||
"message": doc["message"],
|
||||
"timestamp": doc["timestamp"],
|
||||
"score": hit["_score"],
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"向量搜索失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ==================== 全局实例 ====================
|
||||
|
||||
# 创建全局 ES 客户端
|
||||
es_client = ESClient()
|
||||
2383
mcp_server.py
Normal file
2383
mcp_server.py
Normal file
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -18,8 +18,6 @@
|
||||
"@fullcalendar/daygrid": "^5.9.0",
|
||||
"@fullcalendar/interaction": "^5.9.0",
|
||||
"@fullcalendar/react": "^5.9.0",
|
||||
"@react-three/drei": "^9.11.3",
|
||||
"@react-three/fiber": "^8.0.27",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
@@ -39,7 +37,6 @@
|
||||
"fullcalendar": "^5.9.0",
|
||||
"globalize": "^1.7.0",
|
||||
"history": "^5.3.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.540.0",
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
@@ -59,7 +56,6 @@
|
||||
"react-input-pin-code": "^1.1.5",
|
||||
"react-just-parallax": "^3.1.16",
|
||||
"react-jvectormap": "0.0.16",
|
||||
"react-leaflet": "^3.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-quill": "^2.0.0-beta.4",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -82,7 +78,6 @@
|
||||
"styled-components": "^5.3.11",
|
||||
"stylis": "^4.0.10",
|
||||
"stylis-plugin-rtl": "^2.1.1",
|
||||
"three": "^0.142.0",
|
||||
"tsparticles-slim": "^2.12.0"
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -96,12 +91,14 @@
|
||||
"scripts": {
|
||||
"prestart": "kill-port 3000",
|
||||
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
|
||||
"prestart:real": "kill-port 3000",
|
||||
"start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start",
|
||||
"prestart:dev": "kill-port 3000",
|
||||
"start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start",
|
||||
"start:test": "concurrently \"python app_2.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
|
||||
"start:test": "concurrently \"python app.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
|
||||
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
|
||||
"dev": "npm start",
|
||||
"backend": "python app_2.py",
|
||||
"backend": "python app.py",
|
||||
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
|
||||
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||
"test": "craco test --env=jsdom",
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
<!--
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr" layout="admin">
|
||||
<head>
|
||||
@@ -25,10 +7,6 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is added to the
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<link
|
||||
@@ -36,142 +14,11 @@
|
||||
sizes="76x76"
|
||||
href="%PUBLIC_URL%/apple-icon.png"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
|
||||
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.png" or "favicon.png", "%PUBLIC_URL%/favicon.png" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>价值前沿——LLM赋能的分析平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<script>
|
||||
window.difyChatbotConfig = {
|
||||
token: 'DwN8qAKtYFQtWskM',
|
||||
baseUrl: 'https://app.valuefrontier.cn',
|
||||
inputs: {
|
||||
// You can define the inputs from the Start node here
|
||||
// key is the variable name
|
||||
// e.g.
|
||||
// name: "NAME"
|
||||
},
|
||||
systemVariables: {
|
||||
// user_id: 'YOU CAN DEFINE USER ID HERE',
|
||||
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
|
||||
},
|
||||
userVariables: {
|
||||
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
|
||||
// name: 'YOU CAN DEFINE USER NAME HERE',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<script
|
||||
src="https://app.valuefrontier.cn/embed.min.js"
|
||||
id="DwN8qAKtYFQtWskM"
|
||||
defer>
|
||||
</script>
|
||||
<style>
|
||||
#dify-chatbot-bubble-button {
|
||||
background-color: #1C64F2 !important;
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
box-shadow: 0 4px 12px rgba(28, 100, 242, 0.3) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
#dify-chatbot-bubble-button:hover {
|
||||
transform: scale(1.1) !important;
|
||||
box-shadow: 0 6px 16px rgba(28, 100, 242, 0.4) !important;
|
||||
}
|
||||
|
||||
#dify-chatbot-bubble-window {
|
||||
width: 42rem !important;
|
||||
height: 80vh !important;
|
||||
max-height: calc(100vh - 2rem) !important;
|
||||
position: fixed !important;
|
||||
bottom: 100px !important;
|
||||
right: 20px !important;
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15) !important;
|
||||
border: 1px solid rgba(28, 100, 242, 0.1) !important;
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
/* 确保Dify聊天窗口中的超链接正确显示 */
|
||||
#dify-chatbot-bubble-window a,
|
||||
#dify-chatbot-bubble-window a:link,
|
||||
#dify-chatbot-bubble-window a:visited,
|
||||
#dify-chatbot-bubble-window a:hover,
|
||||
#dify-chatbot-bubble-window a:active {
|
||||
color: #1C64F2 !important;
|
||||
text-decoration: underline !important;
|
||||
cursor: pointer !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* 确保超链接在Dify消息区域中可见 */
|
||||
#dify-chatbot-bubble-window .message-content a,
|
||||
#dify-chatbot-bubble-window .markdown-content a,
|
||||
#dify-chatbot-bubble-window [class*="message"] a {
|
||||
color: #0066cc !important;
|
||||
text-decoration: underline !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* 桌面端大屏优化 */
|
||||
@media (min-width: 1440px) {
|
||||
#dify-chatbot-bubble-window {
|
||||
width: 45rem !important;
|
||||
height: 85vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板端适配 */
|
||||
@media (max-width: 1024px) and (min-width: 641px) {
|
||||
#dify-chatbot-bubble-window {
|
||||
width: 38rem !important;
|
||||
height: 75vh !important;
|
||||
right: 15px !important;
|
||||
bottom: 90px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 640px) {
|
||||
#dify-chatbot-bubble-window {
|
||||
width: calc(100vw - 20px) !important;
|
||||
height: 85vh !important;
|
||||
max-height: 85vh !important;
|
||||
right: 10px !important;
|
||||
bottom: 80px !important;
|
||||
left: 10px !important;
|
||||
}
|
||||
|
||||
#dify-chatbot-bubble-button {
|
||||
width: 56px !important;
|
||||
height: 56px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
/**
|
||||
* Service Worker for Browser Notifications
|
||||
* 主要功能:支持浏览器通知的稳定运行
|
||||
*
|
||||
* 注意:此 Service Worker 仅用于通知功能,不拦截任何 HTTP 请求
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'valuefrontier-v1';
|
||||
|
||||
// Service Worker 安装事件
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] Installing...');
|
||||
@@ -35,7 +35,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
.then((windowClients) => {
|
||||
// 查找是否已有打开的窗口
|
||||
for (let client of windowClients) {
|
||||
if (client.url.includes(window.location.origin) && 'focus' in client) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
// 聚焦现有窗口并导航到目标页面
|
||||
return client.focus().then(client => {
|
||||
return client.navigate(urlToOpen);
|
||||
@@ -56,18 +56,6 @@ self.addEventListener('notificationclose', (event) => {
|
||||
console.log('[Service Worker] Notification closed:', event.notification.tag);
|
||||
});
|
||||
|
||||
// Fetch 事件 - 基础的网络优先策略
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// 对于通知相关的资源,使用网络优先策略
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.catch(() => {
|
||||
// 网络失败时,尝试从缓存获取
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 推送消息事件(预留,用于未来的 Push API 集成)
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[Service Worker] Push message received:', event);
|
||||
|
||||
157
src/bytedesk-integration/.env.bytedesk.example
Normal file
157
src/bytedesk-integration/.env.bytedesk.example
Normal file
@@ -0,0 +1,157 @@
|
||||
################################################################################
|
||||
# Bytedesk客服系统环境变量配置示例
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. 复制本文件到vf_react项目根目录(与package.json同级)
|
||||
# cp bytedesk-integration/.env.bytedesk.example .env.local
|
||||
#
|
||||
# 2. 根据实际部署环境修改配置值
|
||||
#
|
||||
# 3. 重启开发服务器使配置生效
|
||||
# npm start
|
||||
#
|
||||
# 注意事项:
|
||||
# - .env.local文件不应提交到Git(已在.gitignore中)
|
||||
# - 开发环境和生产环境应使用不同的配置文件
|
||||
# - 所有以REACT_APP_开头的变量会被打包到前端代码中
|
||||
################################################################################
|
||||
|
||||
# ============================================================================
|
||||
# Bytedesk服务器配置(必需)
|
||||
# ============================================================================
|
||||
|
||||
# Bytedesk后端服务地址(生产环境)
|
||||
# 格式: http://IP地址 或 https://域名
|
||||
# 示例: http://43.143.189.195 或 https://kefu.yourdomain.com
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# ============================================================================
|
||||
# Bytedesk组织和工作组配置(必需)
|
||||
# ============================================================================
|
||||
|
||||
# 组织 UUID(Organization UUID)
|
||||
# 获取方式: 登录管理后台 -> 设置 -> 组织信息 -> 组织UUID
|
||||
# 注意: 不是"组织代码",是"组织UUID"(df_org_uid)
|
||||
# 当前配置: df_org_uid(默认组织)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组 UUID(Workgroup UUID)
|
||||
# 获取方式: 登录管理后台 -> 客服管理 -> 工作组 -> 工作组UUID
|
||||
# 当前配置: df_wg_uid(默认工作组)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
|
||||
# ============================================================================
|
||||
# 可选配置
|
||||
# ============================================================================
|
||||
|
||||
# 客服类型
|
||||
# 2 = 人工客服(默认)
|
||||
# 1 = 机器人客服
|
||||
# REACT_APP_BYTEDESK_TYPE=2
|
||||
|
||||
# 语言设置
|
||||
# zh-cn = 简体中文(默认)
|
||||
# en = 英语
|
||||
# ja = 日语
|
||||
# ko = 韩语
|
||||
# REACT_APP_BYTEDESK_LOCALE=zh-cn
|
||||
|
||||
# 客服图标位置
|
||||
# bottom-right = 右下角(默认)
|
||||
# bottom-left = 左下角
|
||||
# top-right = 右上角
|
||||
# top-left = 左上角
|
||||
# REACT_APP_BYTEDESK_PLACEMENT=bottom-right
|
||||
|
||||
# 客服图标边距(像素)
|
||||
# REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
|
||||
# REACT_APP_BYTEDESK_MARGIN_SIDE=20
|
||||
|
||||
# 主题模式
|
||||
# system = 跟随系统(默认)
|
||||
# light = 亮色模式
|
||||
# dark = 暗色模式
|
||||
# REACT_APP_BYTEDESK_THEME_MODE=system
|
||||
|
||||
# 主题色(十六进制颜色)
|
||||
# REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
|
||||
|
||||
# 是否自动弹出客服窗口(不推荐)
|
||||
# true = 页面加载后自动弹出
|
||||
# false = 需用户点击图标弹出(默认)
|
||||
# REACT_APP_BYTEDESK_AUTO_POPUP=false
|
||||
|
||||
# ============================================================================
|
||||
# 开发环境专用配置
|
||||
# ============================================================================
|
||||
|
||||
# 开发环境可以使用不同的服务器地址
|
||||
# 取消注释以下行使用本地或测试服务器
|
||||
# REACT_APP_BYTEDESK_API_URL_DEV=http://localhost:9003
|
||||
|
||||
# ============================================================================
|
||||
# 配置示例 - 不同部署场景
|
||||
# ============================================================================
|
||||
|
||||
# ---------- 示例1: 生产环境(域名访问) ----------
|
||||
# REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
|
||||
# REACT_APP_BYTEDESK_ORG=prod_org_12345
|
||||
# REACT_APP_BYTEDESK_SID=prod_wg_sales
|
||||
|
||||
# ---------- 示例2: 测试环境(IP访问) ----------
|
||||
# REACT_APP_BYTEDESK_API_URL=http://192.168.1.100
|
||||
# REACT_APP_BYTEDESK_ORG=test_org_abc
|
||||
# REACT_APP_BYTEDESK_SID=test_wg_support
|
||||
|
||||
# ---------- 示例3: 本地开发环境 ----------
|
||||
# REACT_APP_BYTEDESK_API_URL=http://localhost:9003
|
||||
# REACT_APP_BYTEDESK_ORG=dev_org_local
|
||||
# REACT_APP_BYTEDESK_SID=dev_wg_test
|
||||
|
||||
# ============================================================================
|
||||
# 故障排查
|
||||
# ============================================================================
|
||||
|
||||
# 问题1: 客服图标不显示
|
||||
# 解决方案:
|
||||
# - 检查REACT_APP_BYTEDESK_API_URL是否可访问
|
||||
# - 确认.env文件在项目根目录
|
||||
# - 重启开发服务器(npm start)
|
||||
# - 查看浏览器控制台是否有错误
|
||||
|
||||
# 问题2: 连接不上后端服务
|
||||
# 解决方案:
|
||||
# - 确认后端服务已启动(docker ps查看bytedesk-prod容器)
|
||||
# - 检查CORS配置(后端.env.production中的BYTEDESK_CORS_ALLOWED_ORIGINS)
|
||||
# - 确认防火墙未阻止80/443端口
|
||||
|
||||
# 问题3: ORG或SID配置错误
|
||||
# 解决方案:
|
||||
# - 登录管理后台http://43.143.189.195/admin/
|
||||
# - 导航到"设置" -> "组织信息"获取ORG
|
||||
# - 导航到"客服管理" -> "工作组"获取SID
|
||||
# - 确保复制的ID没有多余空格
|
||||
|
||||
# 问题4: 多工作组场景
|
||||
# 解决方案:
|
||||
# - 可以为不同页面配置不同的SID
|
||||
# - 在bytedesk.config.js中使用条件判断
|
||||
# - 示例: 售后页面用售后组SID,销售页面用销售组SID
|
||||
|
||||
# ============================================================================
|
||||
# 安全提示
|
||||
# ============================================================================
|
||||
|
||||
# 1. 不要在代码中硬编码API地址和ID
|
||||
# 2. .env.local文件不应提交到Git仓库
|
||||
# 3. 生产环境建议使用HTTPS
|
||||
# 4. 定期更新后端服务器的安全补丁
|
||||
# 5. 不要在公开的代码库中暴露组织ID和工作组ID
|
||||
|
||||
# ============================================================================
|
||||
# 更多信息
|
||||
# ============================================================================
|
||||
|
||||
# Bytedesk官方文档: https://docs.bytedesk.com
|
||||
# 技术支持: 访问http://43.143.189.195/chat/联系在线客服
|
||||
# GitHub: https://github.com/Bytedesk/bytedesk
|
||||
237
src/bytedesk-integration/App.jsx.example
Normal file
237
src/bytedesk-integration/App.jsx.example
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* vf_react App.jsx集成示例
|
||||
*
|
||||
* 本文件展示如何在vf_react项目中集成Bytedesk客服系统
|
||||
*
|
||||
* 集成步骤:
|
||||
* 1. 将bytedesk-integration文件夹复制到src/目录
|
||||
* 2. 在App.jsx中导入BytedeskWidget和配置
|
||||
* 3. 添加BytedeskWidget组件(代码如下)
|
||||
* 4. 配置.env文件(参考.env.bytedesk.example)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom'; // 如果使用react-router
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
// ============================================================================
|
||||
// 方案一: 全局集成(推荐)
|
||||
// 适用场景: 客服系统需要在所有页面显示
|
||||
// ============================================================================
|
||||
|
||||
function App() {
|
||||
// ========== vf_react原有代码保持不变 ==========
|
||||
// 这里是您原有的App.jsx代码
|
||||
// 例如: const [user, setUser] = useState(null);
|
||||
// 例如: const [theme, setTheme] = useState('light');
|
||||
// ... 保持原有逻辑不变 ...
|
||||
|
||||
// ========== Bytedesk集成代码开始 ==========
|
||||
|
||||
const location = useLocation(); // 获取当前路径
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
// 根据页面路径决定是否显示客服
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowCustomerService(location.pathname);
|
||||
setShowBytedesk(shouldShow);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 获取Bytedesk配置
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
// 客服加载成功回调
|
||||
const handleBytedeskLoad = (bytedesk) => {
|
||||
console.log('[App] Bytedesk客服系统加载成功', bytedesk);
|
||||
};
|
||||
|
||||
// 客服加载失败回调
|
||||
const handleBytedeskError = (error) => {
|
||||
console.error('[App] Bytedesk客服系统加载失败', error);
|
||||
};
|
||||
|
||||
// ========== Bytedesk集成代码结束 ==========
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* ========== vf_react原有内容保持不变 ========== */}
|
||||
{/* 这里是您原有的App.jsx JSX代码 */}
|
||||
{/* 例如: <Header /> */}
|
||||
{/* 例如: <Router> <Routes> ... </Routes> </Router> */}
|
||||
{/* ... 保持原有结构不变 ... */}
|
||||
|
||||
{/* ========== Bytedesk客服Widget ========== */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
onLoad={handleBytedeskLoad}
|
||||
onError={handleBytedeskError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案二: 带用户信息集成
|
||||
// 适用场景: 需要将登录用户信息传递给客服端
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfigWithUser, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
import { AuthContext } from './contexts/AuthContext'; // 假设您有用户认证Context
|
||||
|
||||
function App() {
|
||||
// 获取登录用户信息
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const location = useLocation();
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowCustomerService(location.pathname);
|
||||
setShowBytedesk(shouldShow);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 根据用户信息生成配置
|
||||
const bytedeskConfig = user
|
||||
? getBytedeskConfigWithUser(user)
|
||||
: getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案三: 条件性加载
|
||||
// 适用场景: 只在特定条件下显示客服(如用户已登录、特定用户角色等)
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有在用户登录且为普通用户时显示客服
|
||||
if (user && user.role === 'customer') {
|
||||
setShowBytedesk(true);
|
||||
} else {
|
||||
setShowBytedesk(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案四: 动态控制显示/隐藏
|
||||
// 适用场景: 需要通过按钮或其他交互控制客服显示
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
const toggleBytedesk = () => {
|
||||
setShowBytedesk(prev => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{/* 自定义客服按钮 *\/}
|
||||
<button onClick={toggleBytedesk} className="custom-service-button">
|
||||
{showBytedesk ? '关闭客服' : '联系客服'}
|
||||
</button>
|
||||
|
||||
{/* 客服Widget *\/}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 重要提示
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 1. CSS样式兼容性
|
||||
* - Bytedesk Widget使用Shadow DOM,不会影响您的全局样式
|
||||
* - Widget的样式可通过config中的theme配置调整
|
||||
*
|
||||
* 2. 性能优化
|
||||
* - Widget脚本采用异步加载,不会阻塞页面渲染
|
||||
* - 建议在非关键页面(如登录、支付页)隐藏客服
|
||||
*
|
||||
* 3. 错误处理
|
||||
* - 如果客服脚本加载失败,不会影响主应用
|
||||
* - 建议添加onError回调进行错误监控
|
||||
*
|
||||
* 4. 调试模式
|
||||
* - 查看浏览器控制台的[Bytedesk]前缀日志
|
||||
* - 检查Network面板确认脚本加载成功
|
||||
*
|
||||
* 5. 生产部署
|
||||
* - 确保.env文件配置正确(特别是REACT_APP_BYTEDESK_API_URL)
|
||||
* - 确保CORS已在后端配置(允许您的前端域名)
|
||||
* - 在管理后台配置正确的工作组ID(sid)
|
||||
*/
|
||||
140
src/bytedesk-integration/components/BytedeskWidget.jsx
Normal file
140
src/bytedesk-integration/components/BytedeskWidget.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Bytedesk客服Widget组件
|
||||
* 用于vf_react项目集成
|
||||
*
|
||||
* 使用方法:
|
||||
* import BytedeskWidget from './components/BytedeskWidget';
|
||||
* import { getBytedeskConfig } from './config/bytedesk.config';
|
||||
*
|
||||
* <BytedeskWidget
|
||||
* config={getBytedeskConfig()}
|
||||
* autoLoad={true}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const BytedeskWidget = ({
|
||||
config,
|
||||
autoLoad = true,
|
||||
onLoad,
|
||||
onError
|
||||
}) => {
|
||||
const scriptRef = useRef(null);
|
||||
const widgetRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果不自动加载或配置未设置,跳过
|
||||
if (!autoLoad || !config) {
|
||||
if (!config) {
|
||||
console.warn('[Bytedesk] 配置未设置,客服组件未加载');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Bytedesk] 开始加载客服Widget...', config);
|
||||
|
||||
// 加载Bytedesk Widget脚本
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
|
||||
script.async = true;
|
||||
script.id = 'bytedesk-web-script';
|
||||
|
||||
script.onload = () => {
|
||||
console.log('[Bytedesk] Widget脚本加载成功');
|
||||
|
||||
try {
|
||||
if (window.BytedeskWeb) {
|
||||
console.log('[Bytedesk] 初始化Widget');
|
||||
const bytedesk = new window.BytedeskWeb(config);
|
||||
bytedesk.init();
|
||||
|
||||
widgetRef.current = bytedesk;
|
||||
console.log('[Bytedesk] Widget初始化成功');
|
||||
|
||||
if (onLoad) {
|
||||
onLoad(bytedesk);
|
||||
}
|
||||
} else {
|
||||
throw new Error('BytedeskWeb对象未定义');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Bytedesk] Widget初始化失败:', error);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('[Bytedesk] Widget脚本加载失败:', error);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加脚本到页面
|
||||
document.body.appendChild(script);
|
||||
scriptRef.current = script;
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
console.log('[Bytedesk] 清理Widget');
|
||||
|
||||
// 移除脚本
|
||||
if (scriptRef.current && document.body.contains(scriptRef.current)) {
|
||||
document.body.removeChild(scriptRef.current);
|
||||
}
|
||||
|
||||
// 移除Widget DOM元素
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
if (el && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理全局对象
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
}
|
||||
};
|
||||
}, [config, autoLoad, onLoad, onError]);
|
||||
|
||||
// 不渲染任何可见元素(Widget会自动插入到body)
|
||||
return <div id="bytedesk-widget-container" style={{ display: 'none' }} />;
|
||||
};
|
||||
|
||||
BytedeskWidget.propTypes = {
|
||||
config: PropTypes.shape({
|
||||
apiUrl: PropTypes.string.isRequired,
|
||||
htmlUrl: PropTypes.string.isRequired,
|
||||
placement: PropTypes.oneOf(['bottom-right', 'bottom-left', 'top-right', 'top-left']),
|
||||
marginBottom: PropTypes.number,
|
||||
marginSide: PropTypes.number,
|
||||
autoPopup: PropTypes.bool,
|
||||
locale: PropTypes.string,
|
||||
bubbleConfig: PropTypes.shape({
|
||||
show: PropTypes.bool,
|
||||
icon: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
subtitle: PropTypes.string,
|
||||
}),
|
||||
theme: PropTypes.shape({
|
||||
mode: PropTypes.oneOf(['light', 'dark', 'system']),
|
||||
backgroundColor: PropTypes.string,
|
||||
textColor: PropTypes.string,
|
||||
}),
|
||||
chatConfig: PropTypes.shape({
|
||||
org: PropTypes.string.isRequired,
|
||||
t: PropTypes.string.isRequired,
|
||||
sid: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}),
|
||||
autoLoad: PropTypes.bool,
|
||||
onLoad: PropTypes.func,
|
||||
onError: PropTypes.func,
|
||||
};
|
||||
|
||||
export default BytedeskWidget;
|
||||
155
src/bytedesk-integration/config/bytedesk.config.js
Normal file
155
src/bytedesk-integration/config/bytedesk.config.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Bytedesk客服配置文件
|
||||
* 通过代理访问 Bytedesk 服务器(解决 HTTPS 混合内容问题)
|
||||
*
|
||||
* 环境变量配置(.env文件):
|
||||
* REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
* REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
*
|
||||
* 架构说明:
|
||||
* - iframe 使用完整域名:https://valuefrontier.cn/bytedesk/chat/
|
||||
* - 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
|
||||
* - 本地:CRACO 代理 /bytedesk → valuefrontier.cn/bytedesk
|
||||
* - 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
|
||||
* - baseUrl 保持官方 CDN(用于加载 SDK 外部模块)
|
||||
*
|
||||
* ⚠️ 注意:需要前端 Nginx 配置 /bytedesk/ 代理规则
|
||||
*/
|
||||
|
||||
// 从环境变量读取配置
|
||||
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
|
||||
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_uid';
|
||||
|
||||
/**
|
||||
* Bytedesk客服基础配置
|
||||
*/
|
||||
export const bytedeskConfig = {
|
||||
// API服务地址(如果 SDK 需要调用 API)
|
||||
apiUrl: '/bytedesk/',
|
||||
// 聊天页面地址(使用完整 HTTPS 域名,通过 /bytedesk/ 代理避免 React Router 冲突)
|
||||
htmlUrl: 'https://valuefrontier.cn/chat/',
|
||||
// SDK 资源基础路径(保持 Bytedesk 官方 CDN,用于加载外部模块)
|
||||
baseUrl: 'https://www.weiyuai.cn',
|
||||
|
||||
// 客服图标位置
|
||||
placement: 'bottom-right', // bottom-right | bottom-left | top-right | top-left
|
||||
|
||||
// 边距设置(像素)
|
||||
marginBottom: 20,
|
||||
marginSide: 20,
|
||||
|
||||
// 自动弹出(不推荐)
|
||||
autoPopup: false,
|
||||
|
||||
// 语言设置
|
||||
locale: 'zh-cn', // zh-cn | en | ja | ko
|
||||
|
||||
// 客服图标配置
|
||||
bubbleConfig: {
|
||||
show: true, // 是否显示客服图标
|
||||
icon: '💬', // 图标(emoji或图片URL)
|
||||
title: '在线客服', // 鼠标悬停标题
|
||||
subtitle: '点击咨询', // 副标题
|
||||
},
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
mode: 'system', // light | dark | system
|
||||
backgroundColor: '#0066FF', // 主题色
|
||||
textColor: '#ffffff', // 文字颜色
|
||||
},
|
||||
|
||||
// 聊天配置(必需)
|
||||
chatConfig: {
|
||||
org: BYTEDESK_ORG, // 组织ID
|
||||
t: '1', // 类型: 1=人工客服, 2=机器人
|
||||
sid: BYTEDESK_SID, // 工作组ID
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取Bytedesk配置(根据环境自动切换)
|
||||
*
|
||||
* @returns {Object} Bytedesk配置对象
|
||||
*/
|
||||
export const getBytedeskConfig = () => {
|
||||
// 所有环境都使用公网地址(不使用代理)
|
||||
return bytedeskConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取带用户信息的配置
|
||||
* 用于已登录用户,自动传递用户信息到客服端
|
||||
*
|
||||
* @param {Object} user - 用户对象
|
||||
* @param {string} user.id - 用户ID
|
||||
* @param {string} user.name - 用户名
|
||||
* @param {string} user.email - 用户邮箱
|
||||
* @param {string} user.mobile - 用户手机号
|
||||
* @returns {Object} 带用户信息的Bytedesk配置
|
||||
*/
|
||||
export const getBytedeskConfigWithUser = (user) => {
|
||||
const config = getBytedeskConfig();
|
||||
|
||||
if (user && user.id) {
|
||||
return {
|
||||
...config,
|
||||
chatConfig: {
|
||||
...config.chatConfig,
|
||||
// 传递用户信息(可选)
|
||||
customParams: {
|
||||
userId: user.id,
|
||||
userName: user.name || 'Guest',
|
||||
userEmail: user.email || '',
|
||||
userMobile: user.mobile || '',
|
||||
source: 'web', // 来源标识
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据页面路径判断是否显示客服
|
||||
*
|
||||
* @param {string} pathname - 当前页面路径
|
||||
* @returns {boolean} 是否显示客服
|
||||
*/
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 在以下页面隐藏客服(黑名单)
|
||||
const blockedPages = [
|
||||
// '/home', // 登录页
|
||||
];
|
||||
|
||||
// 检查是否在黑名单
|
||||
if (blockedPages.some(page => pathname.startsWith(page))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 默认所有页面都显示客服
|
||||
return true;
|
||||
|
||||
/* ============================================
|
||||
白名单模式(备用,需要时取消注释)
|
||||
============================================
|
||||
const allowedPages = [
|
||||
'/', // 首页
|
||||
'/home', // 主页
|
||||
'/products', // 产品页
|
||||
'/pricing', // 价格页
|
||||
'/contact', // 联系我们
|
||||
];
|
||||
|
||||
// 只在白名单页面显示客服
|
||||
return allowedPages.some(page => pathname.startsWith(page));
|
||||
============================================ */
|
||||
};
|
||||
|
||||
export default {
|
||||
bytedeskConfig,
|
||||
getBytedeskConfig,
|
||||
getBytedeskConfigWithUser,
|
||||
shouldShowCustomerService,
|
||||
};
|
||||
@@ -508,19 +508,19 @@ export default function WechatRegister() {
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
|
||||
scrolling="no"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation"
|
||||
allow="clipboard-write"
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
transform: 'scale(0.77) translateY(-35px)',
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px',
|
||||
pointerEvents: 'auto', // 允许点击 │ │
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
pointerEvents: 'auto',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
|
||||
376
src/components/ChatBot/ChatInterface.js
Normal file
376
src/components/ChatBot/ChatInterface.js
Normal file
@@ -0,0 +1,376 @@
|
||||
// src/components/ChatBot/ChatInterface.js
|
||||
// 聊天界面主组件
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Input,
|
||||
IconButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Divider,
|
||||
Badge,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiSend, FiRefreshCw, FiSettings, FiDownload } from 'react-icons/fi';
|
||||
import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import { mcpService } from '../../services/mcpService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* 聊天界面组件
|
||||
*/
|
||||
export const ChatInterface = () => {
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
id: 1,
|
||||
content: '你好!我是AI投资助手,我可以帮你查询股票信息、新闻资讯、概念板块、涨停分析等。请问有什么可以帮到你的?',
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [availableTools, setAvailableTools] = useState([]);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const inputBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 加载可用工具列表
|
||||
useEffect(() => {
|
||||
const loadTools = async () => {
|
||||
const result = await mcpService.listTools();
|
||||
if (result.success) {
|
||||
setAvailableTools(result.data);
|
||||
logger.info('ChatInterface', '已加载MCP工具', { count: result.data.length });
|
||||
}
|
||||
};
|
||||
loadTools();
|
||||
}, []);
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim() || isLoading) return;
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
content: inputValue,
|
||||
isUser: true,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputValue('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 调用MCP服务
|
||||
const response = await mcpService.chat(inputValue, messages);
|
||||
|
||||
let botMessage;
|
||||
if (response.success) {
|
||||
// 根据返回的数据类型构造消息
|
||||
const data = response.data;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: data,
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else if (Array.isArray(data)) {
|
||||
// 数据列表
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: `找到 ${data.length} 条结果:`,
|
||||
isUser: false,
|
||||
type: 'data',
|
||||
data: data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else if (typeof data === 'object') {
|
||||
// 对象数据
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: JSON.stringify(data, null, 2),
|
||||
isUser: false,
|
||||
type: 'markdown',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: '抱歉,我无法理解这个查询结果。',
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: `抱歉,查询失败:${response.error}`,
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
} catch (error) {
|
||||
logger.error('ChatInterface', 'handleSendMessage', error);
|
||||
const errorMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: `抱歉,发生了错误:${error.message}`,
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// 清空对话
|
||||
const handleClearChat = () => {
|
||||
setMessages([
|
||||
{
|
||||
id: 1,
|
||||
content: '对话已清空。有什么可以帮到你的?',
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 复制消息
|
||||
const handleCopyMessage = () => {
|
||||
toast({
|
||||
title: '已复制',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 反馈
|
||||
const handleFeedback = (type) => {
|
||||
logger.info('ChatInterface', 'Feedback', { type });
|
||||
toast({
|
||||
title: type === 'positive' ? '感谢反馈!' : '我们会改进',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 快捷问题
|
||||
const quickQuestions = [
|
||||
'查询贵州茅台的股票信息',
|
||||
'搜索人工智能相关新闻',
|
||||
'今日涨停股票有哪些',
|
||||
'新能源概念板块分析',
|
||||
];
|
||||
|
||||
const handleQuickQuestion = (question) => {
|
||||
setInputValue(question);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// 导出对话
|
||||
const handleExportChat = () => {
|
||||
const chatText = messages
|
||||
.map((msg) => `[${msg.isUser ? '用户' : 'AI'}] ${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);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="100%" bg={bgColor}>
|
||||
{/* 头部工具栏 */}
|
||||
<Flex
|
||||
px={4}
|
||||
py={3}
|
||||
borderBottom="1px"
|
||||
borderColor={borderColor}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">AI投资助手</Text>
|
||||
<Badge colorScheme="green">在线</Badge>
|
||||
{availableTools.length > 0 && (
|
||||
<Badge colorScheme="blue">{availableTools.length} 个工具</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiRefreshCw />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="清空对话"
|
||||
onClick={handleClearChat}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiDownload />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="导出对话"
|
||||
onClick={handleExportChat}
|
||||
/>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<FiSettings />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="设置"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem>模型设置</MenuItem>
|
||||
<MenuItem>快捷指令</MenuItem>
|
||||
<MenuItem>历史记录</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<Box
|
||||
flex="1"
|
||||
overflowY="auto"
|
||||
px={4}
|
||||
py={4}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#CBD5E0',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isUser={message.isUser}
|
||||
onCopy={handleCopyMessage}
|
||||
onFeedback={handleFeedback}
|
||||
/>
|
||||
))}
|
||||
{isLoading && (
|
||||
<Flex justify="flex-start" mb={4}>
|
||||
<Flex align="center" bg={inputBg} px={4} py={3} borderRadius="lg">
|
||||
<Spinner size="sm" mr={2} />
|
||||
<Text fontSize="sm">AI正在思考...</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 快捷问题(仅在消息较少时显示) */}
|
||||
{messages.length <= 2 && (
|
||||
<Box px={4} py={2}>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>快捷问题:</Text>
|
||||
<Flex wrap="wrap" gap={2}>
|
||||
{quickQuestions.map((question, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => handleQuickQuestion(question)}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 输入框 */}
|
||||
<Box px={4} py={3} borderTop="1px" borderColor={borderColor}>
|
||||
<Flex>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="输入消息... (Shift+Enter换行,Enter发送)"
|
||||
bg={inputBg}
|
||||
border="none"
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
mr={2}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiSend />}
|
||||
colorScheme="blue"
|
||||
aria-label="发送"
|
||||
onClick={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
disabled={!inputValue.trim()}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInterface;
|
||||
681
src/components/ChatBot/ChatInterfaceV2.js
Normal file
681
src/components/ChatBot/ChatInterfaceV2.js
Normal file
@@ -0,0 +1,681 @@
|
||||
// src/components/ChatBot/ChatInterfaceV2.js
|
||||
// 重新设计的聊天界面 - 更漂亮、支持Agent模式
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Input,
|
||||
IconButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Divider,
|
||||
Badge,
|
||||
Button,
|
||||
Avatar,
|
||||
Heading,
|
||||
Progress,
|
||||
Fade,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiSend, FiRefreshCw, FiDownload, FiCpu, FiUser, FiZap } from 'react-icons/fi';
|
||||
import { PlanCard } from './PlanCard';
|
||||
import { StepResultCard } from './StepResultCard';
|
||||
import { mcpService } from '../../services/mcpService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Agent消息类型
|
||||
*/
|
||||
const MessageTypes = {
|
||||
USER: 'user',
|
||||
AGENT_THINKING: 'agent_thinking',
|
||||
AGENT_PLAN: 'agent_plan',
|
||||
AGENT_EXECUTING: 'agent_executing',
|
||||
AGENT_RESPONSE: 'agent_response',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
/**
|
||||
* 聊天界面V2组件 - Agent模式
|
||||
*/
|
||||
export const ChatInterfaceV2 = () => {
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
id: 1,
|
||||
type: MessageTypes.AGENT_RESPONSE,
|
||||
content: '你好!我是AI投资研究助手。我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [currentProgress, setCurrentProgress] = useState(0);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const chatBg = useColorModeValue('white', 'gray.800');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
|
||||
const agentBubbleBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 添加消息
|
||||
const addMessage = (message) => {
|
||||
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
|
||||
};
|
||||
|
||||
// 更新最后一条消息
|
||||
const updateLastMessage = (updates) => {
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev];
|
||||
if (newMessages.length > 0) {
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...newMessages[newMessages.length - 1],
|
||||
...updates,
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
};
|
||||
|
||||
// 发送消息(Agent模式 - 流式)
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim() || isProcessing) 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 = [];
|
||||
let executingMessageId = null;
|
||||
|
||||
try {
|
||||
// 1. 显示思考状态
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_THINKING,
|
||||
content: '正在分析你的问题...',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setCurrentProgress(10);
|
||||
|
||||
// 使用 EventSource 接收流式数据
|
||||
const eventSource = new EventSource(
|
||||
`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// 由于 EventSource 不支持 POST,我们使用 fetch + ReadableStream
|
||||
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Agent请求失败');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// 读取流式数据
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop(); // 保留不完整的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// 解析 SSE 消息
|
||||
const eventMatch = line.match(/^event: (.+)$/m);
|
||||
const dataMatch = line.match(/^data: (.+)$/m);
|
||||
|
||||
if (!eventMatch || !dataMatch) continue;
|
||||
|
||||
const event = eventMatch[1];
|
||||
const data = JSON.parse(dataMatch[1]);
|
||||
|
||||
logger.info(`SSE Event: ${event}`, data);
|
||||
|
||||
// 处理不同类型的事件
|
||||
switch (event) {
|
||||
case 'status':
|
||||
if (data.stage === 'planning') {
|
||||
// 移除思考消息,显示规划中
|
||||
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_THINKING,
|
||||
content: data.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setCurrentProgress(20);
|
||||
} else if (data.stage === 'executing') {
|
||||
setCurrentProgress(30);
|
||||
} else if (data.stage === 'summarizing') {
|
||||
setCurrentProgress(90);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plan':
|
||||
// 移除思考消息
|
||||
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
|
||||
|
||||
// 显示执行计划
|
||||
currentPlan = data;
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_PLAN,
|
||||
content: '已制定执行计划',
|
||||
plan: data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setCurrentProgress(30);
|
||||
break;
|
||||
|
||||
case 'step_start':
|
||||
// 如果还没有执行中消息,创建一个
|
||||
if (!executingMessageId) {
|
||||
const executingMsg = {
|
||||
type: MessageTypes.AGENT_EXECUTING,
|
||||
content: '正在执行步骤...',
|
||||
plan: currentPlan,
|
||||
stepResults: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
addMessage(executingMsg);
|
||||
executingMessageId = Date.now();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'step_complete':
|
||||
// 添加步骤结果
|
||||
stepResults.push({
|
||||
step_index: data.step_index,
|
||||
tool: data.tool,
|
||||
status: data.status,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
execution_time: data.execution_time,
|
||||
arguments: data.arguments,
|
||||
});
|
||||
|
||||
// 更新执行中消息
|
||||
setMessages(prev =>
|
||||
prev.map(msg =>
|
||||
msg.type === MessageTypes.AGENT_EXECUTING
|
||||
? { ...msg, stepResults: [...stepResults] }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
|
||||
// 更新进度
|
||||
if (currentPlan) {
|
||||
const progress = 30 + ((data.step_index + 1) / currentPlan.steps.length) * 60;
|
||||
setCurrentProgress(progress);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'summary':
|
||||
// 移除执行中消息
|
||||
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
|
||||
|
||||
// 显示最终结果
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_RESPONSE,
|
||||
content: data.content,
|
||||
plan: currentPlan,
|
||||
stepResults: stepResults,
|
||||
metadata: data.metadata,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setCurrentProgress(100);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
throw new Error(data.message);
|
||||
|
||||
case 'done':
|
||||
logger.info('Stream完成');
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn('未知事件类型:', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Agent chat error', error);
|
||||
|
||||
// 移除思考/执行中消息
|
||||
setMessages(prev => prev.filter(
|
||||
m => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
|
||||
));
|
||||
|
||||
addMessage({
|
||||
type: MessageTypes.ERROR,
|
||||
content: `处理失败:${error.message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: '处理失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setCurrentProgress(0);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// 清空对话
|
||||
const handleClearChat = () => {
|
||||
setMessages([
|
||||
{
|
||||
id: 1,
|
||||
type: MessageTypes.AGENT_RESPONSE,
|
||||
content: '对话已清空。有什么可以帮到你的?',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 导出对话
|
||||
const handleExportChat = () => {
|
||||
const chatText = messages
|
||||
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
|
||||
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : 'AI助手'}] ${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);
|
||||
};
|
||||
|
||||
// 快捷问题
|
||||
const quickQuestions = [
|
||||
'全面分析贵州茅台这只股票',
|
||||
'今日涨停股票有哪些亮点',
|
||||
'新能源概念板块的投资机会',
|
||||
'半导体行业最新动态',
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="100%" bg={bgColor}>
|
||||
{/* 头部 */}
|
||||
<Box
|
||||
bg={chatBg}
|
||||
borderBottom="1px"
|
||||
borderColor={borderColor}
|
||||
px={6}
|
||||
py={4}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={4}>
|
||||
<Avatar
|
||||
size="md"
|
||||
bg="blue.500"
|
||||
icon={<FiCpu fontSize="1.5rem" />}
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Heading size="md">AI投资研究助手</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-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#CBD5E0',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{messages.map((message) => (
|
||||
<Fade in key={message.id}>
|
||||
<MessageRenderer message={message} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息渲染器
|
||||
*/
|
||||
const MessageRenderer = ({ message }) => {
|
||||
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" bg="blue.500" 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 ChatInterfaceV2;
|
||||
72
src/components/ChatBot/EChartsRenderer.js
Normal file
72
src/components/ChatBot/EChartsRenderer.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// src/components/ChatBot/EChartsRenderer.js
|
||||
// ECharts 图表渲染组件
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box, useColorModeValue } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
/**
|
||||
* ECharts 图表渲染组件
|
||||
* @param {Object} option - ECharts 配置对象
|
||||
* @param {number} height - 图表高度(默认 400px)
|
||||
*/
|
||||
export const EChartsRenderer = ({ option, height = 400 }) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || !option) return;
|
||||
|
||||
// 初始化图表
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
// 设置默认主题配置
|
||||
const defaultOption = {
|
||||
backgroundColor: 'transparent',
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
...option,
|
||||
};
|
||||
|
||||
// 设置图表配置
|
||||
chartInstance.current.setOption(defaultOption, true);
|
||||
|
||||
// 响应式调整大小
|
||||
const handleResize = () => {
|
||||
chartInstance.current?.resize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
// chartInstance.current?.dispose(); // 不要销毁,避免重新渲染时闪烁
|
||||
};
|
||||
}, [option]);
|
||||
|
||||
// 组件卸载时销毁图表
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
chartInstance.current?.dispose();
|
||||
chartInstance.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={chartRef}
|
||||
width="100%"
|
||||
height={`${height}px`}
|
||||
bg={bgColor}
|
||||
borderRadius="md"
|
||||
boxShadow="sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
189
src/components/ChatBot/MarkdownWithCharts.js
Normal file
189
src/components/ChatBot/MarkdownWithCharts.js
Normal file
@@ -0,0 +1,189 @@
|
||||
// src/components/ChatBot/MarkdownWithCharts.js
|
||||
// 支持 ECharts 图表的 Markdown 渲染组件
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Alert, AlertIcon, Text, VStack, Code } from '@chakra-ui/react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { EChartsRenderer } from './EChartsRenderer';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 解析 Markdown 内容,提取 ECharts 代码块
|
||||
* @param {string} markdown - Markdown 文本
|
||||
* @returns {Array} - 包含文本和图表的数组
|
||||
*/
|
||||
const parseMarkdownWithCharts = (markdown) => {
|
||||
if (!markdown) return [];
|
||||
|
||||
const parts = [];
|
||||
const echartsRegex = /```echarts\s*\n([\s\S]*?)```/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = echartsRegex.exec(markdown)) !== null) {
|
||||
// 添加代码块前的文本
|
||||
if (match.index > lastIndex) {
|
||||
const textBefore = markdown.substring(lastIndex, match.index).trim();
|
||||
if (textBefore) {
|
||||
parts.push({ type: 'text', content: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 ECharts 配置
|
||||
const chartConfig = match[1].trim();
|
||||
parts.push({ type: 'chart', content: chartConfig });
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastIndex < markdown.length) {
|
||||
const textAfter = markdown.substring(lastIndex).trim();
|
||||
if (textAfter) {
|
||||
parts.push({ type: 'text', content: textAfter });
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到图表,返回整个 markdown 作为文本
|
||||
if (parts.length === 0) {
|
||||
parts.push({ type: 'text', content: markdown });
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
/**
|
||||
* 支持 ECharts 图表的 Markdown 渲染组件
|
||||
* @param {string} content - Markdown 文本
|
||||
*/
|
||||
export const MarkdownWithCharts = ({ content }) => {
|
||||
const parts = parseMarkdownWithCharts(content);
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{parts.map((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
// 渲染普通 Markdown
|
||||
return (
|
||||
<Box key={index}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
// 自定义渲染样式
|
||||
p: ({ children }) => (
|
||||
<Text mb={2} fontSize="sm">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<Text fontSize="xl" fontWeight="bold" mb={3}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<Text fontSize="lg" fontWeight="bold" mb={2}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<Text fontSize="md" fontWeight="bold" mb={2}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<Box as="ul" pl={4} mb={2}>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<Box as="ol" pl={4} mb={2}>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<Box as="li" fontSize="sm" mb={1}>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
code: ({ inline, children }) =>
|
||||
inline ? (
|
||||
<Code fontSize="sm" px={1}>
|
||||
{children}
|
||||
</Code>
|
||||
) : (
|
||||
<Code display="block" p={3} borderRadius="md" fontSize="sm" whiteSpace="pre-wrap">
|
||||
{children}
|
||||
</Code>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<Box
|
||||
borderLeftWidth="4px"
|
||||
borderLeftColor="blue.500"
|
||||
pl={4}
|
||||
py={2}
|
||||
fontStyle="italic"
|
||||
color="gray.600"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{part.content}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
);
|
||||
} else if (part.type === 'chart') {
|
||||
// 渲染 ECharts 图表
|
||||
try {
|
||||
// 清理可能的 Markdown 残留符号
|
||||
let cleanContent = part.content.trim();
|
||||
|
||||
// 移除可能的前后空白和不可见字符
|
||||
cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
|
||||
|
||||
// 尝试解析 JSON
|
||||
const chartOption = JSON.parse(cleanContent);
|
||||
|
||||
// 验证是否是有效的 ECharts 配置
|
||||
if (!chartOption || typeof chartOption !== 'object') {
|
||||
throw new Error('Invalid chart configuration: not an object');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<EChartsRenderer option={chartOption} height={350} />
|
||||
</Box>
|
||||
);
|
||||
} catch (error) {
|
||||
// 记录详细的错误信息
|
||||
logger.error('解析 ECharts 配置失败', {
|
||||
error: error.message,
|
||||
contentLength: part.content.length,
|
||||
contentPreview: part.content.substring(0, 200),
|
||||
errorStack: error.stack
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert status="warning" key={index} borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="flex-start" spacing={1} flex="1">
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
图表配置解析失败
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
错误: {error.message}
|
||||
</Text>
|
||||
<Code fontSize="xs" maxW="100%" overflow="auto" whiteSpace="pre-wrap">
|
||||
{part.content.substring(0, 300)}
|
||||
{part.content.length > 300 ? '...' : ''}
|
||||
</Code>
|
||||
</VStack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
149
src/components/ChatBot/MessageBubble.js
Normal file
149
src/components/ChatBot/MessageBubble.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// src/components/ChatBot/MessageBubble.js
|
||||
// 聊天消息气泡组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Avatar,
|
||||
useColorModeValue,
|
||||
IconButton,
|
||||
HStack,
|
||||
Code,
|
||||
Badge,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiCopy, FiThumbsUp, FiThumbsDown } from 'react-icons/fi';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
/**
|
||||
* 消息气泡组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.message - 消息对象
|
||||
* @param {boolean} props.isUser - 是否是用户消息
|
||||
* @param {Function} props.onCopy - 复制消息回调
|
||||
* @param {Function} props.onFeedback - 反馈回调
|
||||
*/
|
||||
export const MessageBubble = ({ message, isUser, onCopy, onFeedback }) => {
|
||||
const userBg = useColorModeValue('blue.500', 'blue.600');
|
||||
const botBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const userColor = 'white';
|
||||
const botColor = useColorModeValue('gray.800', 'white');
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
w="100%"
|
||||
justify={isUser ? 'flex-end' : 'flex-start'}
|
||||
mb={4}
|
||||
>
|
||||
<Flex
|
||||
maxW="75%"
|
||||
flexDirection={isUser ? 'row-reverse' : 'row'}
|
||||
align="flex-start"
|
||||
>
|
||||
{/* 头像 */}
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={isUser ? '用户' : 'AI助手'}
|
||||
bg={isUser ? 'blue.500' : 'green.500'}
|
||||
color="white"
|
||||
mx={3}
|
||||
/>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<Box>
|
||||
<Box
|
||||
bg={isUser ? userBg : botBg}
|
||||
color={isUser ? userColor : botColor}
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
>
|
||||
{message.type === 'text' ? (
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{message.content}
|
||||
</Text>
|
||||
) : message.type === 'markdown' ? (
|
||||
<Box fontSize="sm" className="markdown-content">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</Box>
|
||||
) : message.type === 'data' ? (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{message.data && Array.isArray(message.data) && message.data.slice(0, 5).map((item, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={3}
|
||||
bg={useColorModeValue('white', 'gray.600')}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
>
|
||||
{Object.entries(item).map(([key, value]) => (
|
||||
<Flex key={key} justify="space-between" mb={1}>
|
||||
<Text fontWeight="bold" mr={2}>{key}:</Text>
|
||||
<Text>{String(value)}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{message.data && message.data.length > 5 && (
|
||||
<Badge colorScheme="blue" alignSelf="center">
|
||||
+{message.data.length - 5} 更多结果
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{/* 消息操作按钮(仅AI消息) */}
|
||||
{!isUser && (
|
||||
<HStack mt={2} spacing={2}>
|
||||
<IconButton
|
||||
icon={<FiCopy />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="复制"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiThumbsUp />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="赞"
|
||||
onClick={() => onFeedback?.('positive')}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiThumbsDown />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="踩"
|
||||
onClick={() => onFeedback?.('negative')}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 时间戳 */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
mt={1}
|
||||
textAlign={isUser ? 'right' : 'left'}
|
||||
>
|
||||
{message.timestamp ? new Date(message.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBubble;
|
||||
145
src/components/ChatBot/PlanCard.js
Normal file
145
src/components/ChatBot/PlanCard.js
Normal file
@@ -0,0 +1,145 @@
|
||||
// src/components/ChatBot/PlanCard.js
|
||||
// 执行计划展示卡片
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiTarget, FiCheckCircle, FiXCircle, FiClock, FiTool } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* 执行计划卡片组件
|
||||
*/
|
||||
export const PlanCard = ({ plan, stepResults }) => {
|
||||
const cardBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const borderColor = useColorModeValue('blue.200', 'blue.700');
|
||||
const successColor = useColorModeValue('green.500', 'green.300');
|
||||
const errorColor = useColorModeValue('red.500', 'red.300');
|
||||
const pendingColor = useColorModeValue('gray.400', 'gray.500');
|
||||
|
||||
const getStepStatus = (stepIndex) => {
|
||||
if (!stepResults || stepResults.length === 0) return 'pending';
|
||||
const result = stepResults.find(r => r.step_index === stepIndex);
|
||||
return result ? result.status : 'pending';
|
||||
};
|
||||
|
||||
const getStepIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return FiCheckCircle;
|
||||
case 'failed':
|
||||
return FiXCircle;
|
||||
default:
|
||||
return FiClock;
|
||||
}
|
||||
};
|
||||
|
||||
const getStepColor = (status) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return successColor;
|
||||
case 'failed':
|
||||
return errorColor;
|
||||
default:
|
||||
return pendingColor;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="lg"
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
p={4}
|
||||
mb={4}
|
||||
boxShadow="md"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 目标 */}
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="blue.500" boxSize={5} />
|
||||
<Text fontWeight="bold" fontSize="md">执行目标</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="gray.600" pl={7}>
|
||||
{plan.goal}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 规划思路 */}
|
||||
{plan.reasoning && (
|
||||
<>
|
||||
<Text fontSize="sm" fontWeight="bold">规划思路:</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{plan.reasoning}
|
||||
</Text>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 执行步骤 */}
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" fontWeight="bold">执行步骤</Text>
|
||||
<Badge colorScheme="blue">{plan.steps.length} 步</Badge>
|
||||
</HStack>
|
||||
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{plan.steps.map((step, index) => {
|
||||
const status = getStepStatus(index);
|
||||
const StepIcon = getStepIcon(status);
|
||||
const stepColor = getStepColor(status);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
key={index}
|
||||
p={2}
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={stepColor}
|
||||
align="flex-start"
|
||||
>
|
||||
<Icon as={StepIcon} color={stepColor} boxSize={4} mt={1} />
|
||||
<VStack align="stretch" flex={1} spacing={1}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
步骤 {index + 1}: {step.tool}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={
|
||||
status === 'success' ? 'green' :
|
||||
status === 'failed' ? 'red' : 'gray'
|
||||
}
|
||||
fontSize="xs"
|
||||
>
|
||||
{status === 'success' ? '✓ 完成' :
|
||||
status === 'failed' ? '✗ 失败' : '⏳ 等待'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{step.reason}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
186
src/components/ChatBot/StepResultCard.js
Normal file
186
src/components/ChatBot/StepResultCard.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// src/components/ChatBot/StepResultCard.js
|
||||
// 步骤结果展示卡片(可折叠)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Collapse,
|
||||
Icon,
|
||||
IconButton,
|
||||
Code,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiChevronDown, FiChevronUp, FiCheckCircle, FiXCircle, FiClock, FiDatabase } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* 步骤结果卡片组件
|
||||
*/
|
||||
export const StepResultCard = ({ stepResult }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const successColor = useColorModeValue('green.500', 'green.300');
|
||||
const errorColor = useColorModeValue('red.500', 'red.300');
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (stepResult.status) {
|
||||
case 'success':
|
||||
return FiCheckCircle;
|
||||
case 'failed':
|
||||
return FiXCircle;
|
||||
default:
|
||||
return FiClock;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (stepResult.status) {
|
||||
case 'success':
|
||||
return 'green';
|
||||
case 'failed':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const StatusIcon = getStatusIcon();
|
||||
const statusColorScheme = getStatusColor();
|
||||
|
||||
// 格式化数据以便展示
|
||||
const formatResult = (data) => {
|
||||
if (typeof data === 'string') return data;
|
||||
if (Array.isArray(data)) {
|
||||
return `找到 ${data.length} 条记录`;
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
return String(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
>
|
||||
{/* 头部 - 始终可见 */}
|
||||
<HStack
|
||||
p={3}
|
||||
justify="space-between"
|
||||
cursor="pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
_hover={{ bg: useColorModeValue('gray.50', 'gray.600') }}
|
||||
>
|
||||
<HStack flex={1}>
|
||||
<Icon as={StatusIcon} color={`${statusColorScheme}.500`} boxSize={5} />
|
||||
<VStack align="stretch" spacing={0} flex={1}>
|
||||
<HStack>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
步骤 {stepResult.step_index + 1}: {stepResult.tool}
|
||||
</Text>
|
||||
<Badge colorScheme={statusColorScheme} fontSize="xs">
|
||||
{stepResult.status === 'success' ? '成功' :
|
||||
stepResult.status === 'failed' ? '失败' : '执行中'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
耗时: {stepResult.execution_time?.toFixed(2)}s
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<IconButton
|
||||
icon={<Icon as={isExpanded ? FiChevronUp : FiChevronDown} />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isExpanded ? "收起" : "展开"}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 内容 - 可折叠 */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box p={3} pt={0}>
|
||||
<Divider mb={3} />
|
||||
|
||||
{/* 参数 */}
|
||||
{stepResult.arguments && Object.keys(stepResult.arguments).length > 0 && (
|
||||
<VStack align="stretch" spacing={2} mb={3}>
|
||||
<HStack>
|
||||
<Icon as={FiDatabase} color="blue.500" boxSize={4} />
|
||||
<Text fontSize="xs" fontWeight="bold">请求参数:</Text>
|
||||
</HStack>
|
||||
<Code
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
whiteSpace="pre-wrap"
|
||||
wordBreak="break-word"
|
||||
>
|
||||
{JSON.stringify(stepResult.arguments, null, 2)}
|
||||
</Code>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 结果或错误 */}
|
||||
{stepResult.status === 'success' && stepResult.result && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" fontWeight="bold">执行结果:</Text>
|
||||
<Box
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
p={2}
|
||||
bg={useColorModeValue('gray.50', 'gray.800')}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
>
|
||||
{typeof stepResult.result === 'string' ? (
|
||||
<Text whiteSpace="pre-wrap">{stepResult.result}</Text>
|
||||
) : Array.isArray(stepResult.result) ? (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontWeight="bold">找到 {stepResult.result.length} 条记录:</Text>
|
||||
{stepResult.result.slice(0, 3).map((item, idx) => (
|
||||
<Code key={idx} p={2} borderRadius="md" fontSize="xs">
|
||||
{JSON.stringify(item, null, 2)}
|
||||
</Code>
|
||||
))}
|
||||
{stepResult.result.length > 3 && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
...还有 {stepResult.result.length - 3} 条记录
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<Code whiteSpace="pre-wrap" wordBreak="break-word">
|
||||
{JSON.stringify(stepResult.result, null, 2)}
|
||||
</Code>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{stepResult.status === 'failed' && stepResult.error && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" fontWeight="bold" color="red.500">错误信息:</Text>
|
||||
<Text fontSize="xs" color="red.600" p={2} bg="red.50" borderRadius="md">
|
||||
{stepResult.error}
|
||||
</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepResultCard;
|
||||
11
src/components/ChatBot/index.js
Normal file
11
src/components/ChatBot/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// src/components/ChatBot/index.js
|
||||
// 聊天机器人组件统一导出
|
||||
|
||||
export { ChatInterface } from './ChatInterface';
|
||||
export { ChatInterfaceV2 } from './ChatInterfaceV2';
|
||||
export { MessageBubble } from './MessageBubble';
|
||||
export { PlanCard } from './PlanCard';
|
||||
export { StepResultCard } from './StepResultCard';
|
||||
|
||||
// 默认导出新版本
|
||||
export { ChatInterfaceV2 as default } from './ChatInterfaceV2';
|
||||
@@ -2,6 +2,7 @@
|
||||
// 集中管理应用的全局组件
|
||||
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
@@ -12,6 +13,10 @@ import NotificationTestTool from './NotificationTestTool';
|
||||
import ConnectionStatusBar from './ConnectionStatusBar';
|
||||
import ScrollToTop from './ScrollToTop';
|
||||
|
||||
// Bytedesk客服组件
|
||||
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
* 需要在 NotificationProvider 内部使用,所以在这里包装
|
||||
@@ -67,8 +72,12 @@ function ConnectionStatusBarWrapper() {
|
||||
* - AuthModalManager: 认证弹窗管理器
|
||||
* - NotificationContainer: 通知容器
|
||||
* - NotificationTestTool: 通知测试工具 (仅开发环境)
|
||||
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
|
||||
*/
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
const showBytedesk = shouldShowCustomerService(location.pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Socket 连接状态条 */}
|
||||
@@ -85,6 +94,14 @@ export function GlobalComponents() {
|
||||
|
||||
{/* 通知测试工具 (仅开发环境) */}
|
||||
<NotificationTestTool />
|
||||
|
||||
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.leaflet-container {
|
||||
height: 300px;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { MapContainer, TileLayer, } from 'react-leaflet';
|
||||
import "./Map.css";
|
||||
|
||||
function MapPlaceholder() {
|
||||
return (
|
||||
<p>
|
||||
Map of London.{' '}
|
||||
<noscript>You need to enable JavaScript to see this map.</noscript>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function Map() {
|
||||
return (
|
||||
<MapContainer
|
||||
center={[51.505, -0.09]}
|
||||
zoom={13}
|
||||
scrollWheelZoom={true}
|
||||
placeholder={<MapPlaceholder />}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Map;
|
||||
@@ -243,6 +243,26 @@ const MobileDrawer = memo(({
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>AGENT社群</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Link
|
||||
onClick={() => handleNavigate('/agent-chat')}
|
||||
py={1}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="xs" colorScheme="green">AI</Badge>
|
||||
<Badge size="xs" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Link>
|
||||
<Link
|
||||
py={1}
|
||||
px={3}
|
||||
|
||||
@@ -199,6 +199,12 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
onClick={agentCommunityMenu.handleClick}
|
||||
@@ -207,10 +213,31 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</MenuButton>
|
||||
<MenuList
|
||||
minW="300px"
|
||||
p={4}
|
||||
p={2}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('AI聊天助手', 'dropdown', '/agent-chat');
|
||||
navigate('/agent-chat');
|
||||
agentCommunityMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">AI</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
|
||||
@@ -139,6 +139,22 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
|
||||
{/* AGENT社群组 */}
|
||||
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">AGENT社群</Text>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/agent-chat');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">AI</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
</MenuItem>
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { SOCKET_TYPE } from '../../services/socket';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
|
||||
|
||||
const NotificationTestTool = () => {
|
||||
@@ -295,7 +294,7 @@ const NotificationTestTool = () => {
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
{SOCKET_TYPE}
|
||||
REAL
|
||||
</Badge>
|
||||
<Badge colorScheme={getPermissionColor()}>
|
||||
浏览器: {getPermissionLabel()}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import React from 'react';
|
||||
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import { TriangleUpIcon, TriangleDownIcon } from '@chakra-ui/icons';
|
||||
import { getChangeColor } from '../utils/colorUtils';
|
||||
|
||||
/**
|
||||
* 股票涨跌幅指标组件(3分天下布局)
|
||||
@@ -23,19 +24,14 @@ const StockChangeIndicators = ({
|
||||
const isComfortable = size === 'comfortable';
|
||||
const isDefault = size === 'default';
|
||||
|
||||
// 根据涨跌幅获取数字颜色(统一颜色,不分级)
|
||||
// 根据涨跌幅获取数字颜色(动态深浅)
|
||||
const getNumberColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.700', 'gray.400');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色
|
||||
if (value === 0) {
|
||||
return 'gray.700';
|
||||
}
|
||||
|
||||
// 统一颜色:上涨红色,下跌绿色
|
||||
return value > 0 ? 'red.500' : 'green.500';
|
||||
// 使用动态颜色函数
|
||||
return getChangeColor(value);
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取背景色(永远比文字色浅)
|
||||
|
||||
@@ -23,6 +23,8 @@ export const IMPORTANCE_LEVELS = {
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
stampText: '极', // 印章文字
|
||||
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
|
||||
dotBg: 'red.800',
|
||||
description: '重大事件,市场影响深远',
|
||||
antdColor: '#cf1322',
|
||||
@@ -37,6 +39,8 @@ export const IMPORTANCE_LEVELS = {
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
stampText: '高', // 印章文字
|
||||
stampFont: "'STXingkai', 'FangSong', 'STFangsong', cursive", // 行楷/草书
|
||||
dotBg: 'red.600',
|
||||
description: '重要事件,影响较大',
|
||||
antdColor: '#ff4d4f',
|
||||
@@ -51,6 +55,8 @@ export const IMPORTANCE_LEVELS = {
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
stampText: '中', // 印章文字
|
||||
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
|
||||
dotBg: 'red.500',
|
||||
description: '普通事件,有一定影响',
|
||||
antdColor: '#ff7875',
|
||||
@@ -61,10 +67,12 @@ export const IMPORTANCE_LEVELS = {
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.100',
|
||||
colorScheme: 'red',
|
||||
badgeBg: '#6b7280', // 圆形徽章背景色 - 灰色
|
||||
badgeBg: '#10b981', // 圆形徽章背景色 - 青绿色(替代灰色)
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
stampText: '低', // 印章文字
|
||||
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
|
||||
dotBg: 'red.400',
|
||||
description: '参考事件,影响有限',
|
||||
antdColor: '#ffa39e',
|
||||
|
||||
@@ -58,7 +58,9 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
// 创建超时控制器
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort(new Error('Session check timeout after 5 seconds'));
|
||||
}, 5000); // 5秒超时
|
||||
|
||||
const response = await fetch(`/api/auth/session`, {
|
||||
method: 'GET',
|
||||
@@ -96,8 +98,18 @@ export const AuthProvider = ({ children }) => {
|
||||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'checkSession', error);
|
||||
// 网络错误或超时,设置为未登录状态
|
||||
// ✅ 区分AbortError和真实错误
|
||||
if (error.name === 'AbortError') {
|
||||
logger.debug('AuthContext', 'Session check aborted', {
|
||||
reason: error.message || 'Request cancelled',
|
||||
isTimeout: error.message?.includes('timeout')
|
||||
});
|
||||
// AbortError不改变登录状态(保持原状态)
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有真实错误才标记为未登录
|
||||
logger.error('AuthContext', 'checkSession failed', error);
|
||||
setUser((prev) => prev === null ? prev : null);
|
||||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||||
} finally {
|
||||
@@ -108,7 +120,16 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
// ⚡ 初始化时检查Session - 并行执行,不阻塞页面渲染
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// 传递signal给checkSession(需要修改checkSession签名)
|
||||
// 暂时使用原有方式,但添加cleanup防止组件卸载时的内存泄漏
|
||||
checkSession(); // 直接调用,与页面渲染并行
|
||||
|
||||
// ✅ Cleanup: 组件卸载时abort可能正在进行的请求
|
||||
return () => {
|
||||
controller.abort(new Error('AuthProvider unmounted'));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,20 +2,15 @@
|
||||
/**
|
||||
* 通知上下文 - 管理实时消息推送和通知显示
|
||||
*
|
||||
* 环境说明:
|
||||
* - SOCKET_TYPE === 'REAL': 使用真实 Socket.IO 连接(生产环境),连接到 wss://valuefrontier.cn
|
||||
* - SOCKET_TYPE === 'MOCK': 使用模拟 Socket 服务(开发环境),用于本地测试
|
||||
*
|
||||
* 环境切换:
|
||||
* - 设置 REACT_APP_ENABLE_MOCK=true 或 REACT_APP_USE_MOCK_SOCKET=true 使用 MOCK 模式
|
||||
* - 否则使用 REAL 模式连接生产环境
|
||||
* 使用真实 Socket.IO 连接到后端服务器
|
||||
* 连接地址配置在环境变量中 (REACT_APP_API_URL)
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
|
||||
import { BellIcon } from '@chakra-ui/icons';
|
||||
import { logger } from '../utils/logger';
|
||||
import socket, { SOCKET_TYPE } from '../services/socket';
|
||||
import socket from '../services/socket';
|
||||
import notificationSound from '../assets/sounds/notification.wav';
|
||||
import { browserNotificationService } from '../services/browserNotificationService';
|
||||
import { notificationMetricsService } from '../services/notificationMetricsService';
|
||||
@@ -62,6 +57,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态
|
||||
const processedEventIds = useRef(new Set()); // 用于Socket层去重,记录已处理的事件ID
|
||||
const MAX_PROCESSED_IDS = 1000; // 最多保留1000个ID,避免内存泄漏
|
||||
const notificationTimers = useRef(new Map()); // 跟踪所有通知的自动关闭定时器
|
||||
|
||||
// ⚡ 方案2: 使用 Ref 存储最新的回调函数引用(避免闭包陷阱)
|
||||
const addNotificationRef = useRef(null);
|
||||
const adaptEventToNotificationRef = useRef(null);
|
||||
const isFirstConnect = useRef(true); // 标记是否首次连接
|
||||
|
||||
// ⚡ 使用权限引导管理 Hook
|
||||
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
|
||||
@@ -71,9 +72,20 @@ export const NotificationProvider = ({ children }) => {
|
||||
try {
|
||||
audioRef.current = new Audio(notificationSound);
|
||||
audioRef.current.volume = 0.5;
|
||||
logger.info('NotificationContext', 'Audio initialized');
|
||||
} catch (error) {
|
||||
logger.error('NotificationContext', 'Audio initialization failed', error);
|
||||
}
|
||||
|
||||
// 清理函数:释放音频资源
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
audioRef.current = null;
|
||||
logger.info('NotificationContext', 'Audio resources cleaned up');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -104,6 +116,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
const removeNotification = useCallback((id, wasClicked = false) => {
|
||||
logger.info('NotificationContext', 'Removing notification', { id, wasClicked });
|
||||
|
||||
// 清理对应的定时器
|
||||
if (notificationTimers.current.has(id)) {
|
||||
clearTimeout(notificationTimers.current.get(id));
|
||||
notificationTimers.current.delete(id);
|
||||
logger.info('NotificationContext', 'Cleared auto-close timer', { id });
|
||||
}
|
||||
|
||||
// 监控埋点:追踪关闭(非点击的情况)
|
||||
setNotifications(prev => {
|
||||
const notification = prev.find(n => n.id === id);
|
||||
@@ -119,6 +138,14 @@ export const NotificationProvider = ({ children }) => {
|
||||
*/
|
||||
const clearAllNotifications = useCallback(() => {
|
||||
logger.info('NotificationContext', 'Clearing all notifications');
|
||||
|
||||
// 清理所有定时器
|
||||
notificationTimers.current.forEach((timerId, id) => {
|
||||
clearTimeout(timerId);
|
||||
logger.info('NotificationContext', 'Cleared timer during clear all', { id });
|
||||
});
|
||||
notificationTimers.current.clear();
|
||||
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
@@ -292,8 +319,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
* 发送浏览器通知
|
||||
*/
|
||||
const sendBrowserNotification = useCallback((notificationData) => {
|
||||
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
|
||||
console.log('[NotificationContext] 通知数据:', notificationData);
|
||||
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
|
||||
|
||||
if (browserPermission !== 'granted') {
|
||||
logger.warn('NotificationContext', 'Browser permission not granted');
|
||||
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,6 +337,14 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 判断是否需要用户交互(紧急通知不自动关闭)
|
||||
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
|
||||
|
||||
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
|
||||
title,
|
||||
body: content,
|
||||
tag,
|
||||
requireInteraction,
|
||||
link
|
||||
});
|
||||
|
||||
// 发送浏览器通知
|
||||
const notification = browserNotificationService.sendNotification({
|
||||
title: title || '新通知',
|
||||
@@ -315,17 +355,24 @@ export const NotificationProvider = ({ children }) => {
|
||||
autoClose: requireInteraction ? 0 : 8000,
|
||||
});
|
||||
|
||||
// 设置点击处理(聚焦窗口并跳转)
|
||||
if (notification && link) {
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
// 使用 window.location 跳转(不需要 React Router)
|
||||
window.location.hash = link;
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
if (notification) {
|
||||
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
|
||||
|
||||
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
|
||||
// 设置点击处理(聚焦窗口并跳转)
|
||||
if (link) {
|
||||
notification.onclick = () => {
|
||||
console.log('[NotificationContext] 通知被点击,跳转到:', link);
|
||||
window.focus();
|
||||
// 使用 window.location 跳转(不需要 React Router)
|
||||
window.location.hash = link;
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ 通知对象创建失败!');
|
||||
}
|
||||
}, [browserPermission]);
|
||||
|
||||
/**
|
||||
@@ -426,9 +473,16 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// 自动关闭
|
||||
if (newNotification.autoClose && newNotification.autoClose > 0) {
|
||||
setTimeout(() => {
|
||||
const timerId = setTimeout(() => {
|
||||
removeNotification(newNotification.id);
|
||||
}, newNotification.autoClose);
|
||||
|
||||
// 将定时器ID保存到Map中
|
||||
notificationTimers.current.set(newNotification.id, timerId);
|
||||
logger.info('NotificationContext', 'Set auto-close timer', {
|
||||
id: newNotification.id,
|
||||
delay: newNotification.autoClose
|
||||
});
|
||||
}
|
||||
}, [playNotificationSound, removeNotification]);
|
||||
|
||||
@@ -528,34 +582,11 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
const isPageHidden = document.hidden; // 页面是否在后台
|
||||
|
||||
// ========== 原分发策略(按优先级区分)- 已废弃 ==========
|
||||
// 策略 1: 紧急通知 - 双重保障(浏览器 + 网页)
|
||||
// if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
// logger.info('NotificationContext', 'Urgent notification: sending browser + web');
|
||||
// // 总是发送浏览器通知
|
||||
// sendBrowserNotification(newNotification);
|
||||
// // 如果在前台,也显示网页通知
|
||||
// if (!isPageHidden) {
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
// }
|
||||
// 策略 2: 重要通知 - 智能切换(后台=浏览器,前台=网页)
|
||||
// else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
// if (isPageHidden) {
|
||||
// logger.info('NotificationContext', 'Important notification (background): sending browser');
|
||||
// sendBrowserNotification(newNotification);
|
||||
// } else {
|
||||
// logger.info('NotificationContext', 'Important notification (foreground): sending web');
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
// }
|
||||
// 策略 3: 普通通知 - 仅网页通知
|
||||
// else {
|
||||
// logger.info('NotificationContext', 'Normal notification: sending web only');
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
|
||||
// ========== 新分发策略(仅区分前后台) ==========
|
||||
// ========== 通知分发策略(区分前后台) ==========
|
||||
// 策略: 根据页面可见性智能分发通知
|
||||
// - 页面在后台: 发送浏览器通知(系统级提醒)
|
||||
// - 页面在前台: 发送网页通知(页面内 Toast)
|
||||
// 注: 不再区分优先级,统一使用前后台策略
|
||||
if (isPageHidden) {
|
||||
// 页面在后台:发送浏览器通知
|
||||
logger.info('NotificationContext', 'Page hidden: sending browser notification');
|
||||
@@ -569,26 +600,42 @@ export const NotificationProvider = ({ children }) => {
|
||||
return newNotification.id;
|
||||
}, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
|
||||
|
||||
// 连接到 Socket 服务
|
||||
/**
|
||||
* ✅ 方案2: 同步最新的回调函数到 Ref
|
||||
* 确保 Socket 监听器始终使用最新的函数引用(避免闭包陷阱)
|
||||
*/
|
||||
useEffect(() => {
|
||||
addNotificationRef.current = addNotification;
|
||||
console.log('[NotificationContext] 📝 已更新 addNotificationRef');
|
||||
}, [addNotification]);
|
||||
|
||||
useEffect(() => {
|
||||
adaptEventToNotificationRef.current = adaptEventToNotification;
|
||||
console.log('[NotificationContext] 📝 已更新 adaptEventToNotificationRef');
|
||||
}, [adaptEventToNotification]);
|
||||
|
||||
|
||||
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
|
||||
useEffect(() => {
|
||||
logger.info('NotificationContext', 'Initializing socket connection...');
|
||||
console.log(`%c[NotificationContext] Initializing socket (type: ${SOCKET_TYPE})`, 'color: #673AB7; font-weight: bold;');
|
||||
console.log('%c[NotificationContext] 🚀 初始化 Socket 连接(方案2:只注册一次)', 'color: #673AB7; font-weight: bold;');
|
||||
|
||||
// ✅ 第一步: 注册所有事件监听器
|
||||
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
|
||||
|
||||
// 监听连接状态
|
||||
// ========== 监听连接成功(首次连接 + 重连) ==========
|
||||
socket.on('connect', () => {
|
||||
const wasDisconnected = connectionStatus !== CONNECTION_STATUS.CONNECTED;
|
||||
setIsConnected(true);
|
||||
setReconnectAttempt(0);
|
||||
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
|
||||
console.log('%c[NotificationContext] ✅ Received connect event, updating state to connected', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// 如果之前断开过,显示 RECONNECTED 状态2秒后自动消失
|
||||
if (wasDisconnected) {
|
||||
// 判断是首次连接还是重连
|
||||
if (isFirstConnect.current) {
|
||||
console.log('%c[NotificationContext] ✅ 首次连接成功', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] Socket ID:', socket.getSocketId?.());
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
isFirstConnect.current = false;
|
||||
logger.info('NotificationContext', 'Socket connected (first time)');
|
||||
} else {
|
||||
console.log('%c[NotificationContext] 🔄 重连成功!', 'color: #FF9800; font-weight: bold;');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
|
||||
logger.info('NotificationContext', 'Reconnected, will auto-dismiss in 2s');
|
||||
logger.info('NotificationContext', 'Socket reconnected');
|
||||
|
||||
// 清除之前的定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
@@ -600,116 +647,181 @@ export const NotificationProvider = ({ children }) => {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
|
||||
}, 2000);
|
||||
} else {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
}
|
||||
|
||||
// 如果使用 mock,可以启动定期推送
|
||||
if (SOCKET_TYPE === 'MOCK') {
|
||||
// 启动模拟推送:使用配置的间隔和数量
|
||||
const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
|
||||
socket.startMockPush(interval, maxBatch);
|
||||
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
|
||||
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
|
||||
console.log('%c[NotificationContext] 🔔 重新订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onSubscribed: (data) => {
|
||||
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] 订阅确认:', data);
|
||||
logger.info('NotificationContext', 'Events subscribed', data);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 监听断开连接 ==========
|
||||
socket.on('disconnect', (reason) => {
|
||||
setIsConnected(false);
|
||||
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
|
||||
logger.warn('NotificationContext', 'Socket disconnected', { reason });
|
||||
console.log('%c[NotificationContext] ⚠️ Socket 已断开', 'color: #FF5722;', { reason });
|
||||
});
|
||||
|
||||
// 监听连接错误
|
||||
// ========== 监听连接错误 ==========
|
||||
socket.on('connect_error', (error) => {
|
||||
logger.error('NotificationContext', 'Socket connect_error', error);
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
|
||||
|
||||
// 获取重连次数(Real 和 Mock 都支持)
|
||||
const attempts = socket.getReconnectAttempts?.() || 0;
|
||||
setReconnectAttempt(attempts);
|
||||
logger.info('NotificationContext', 'Reconnection attempt', { attempts, socketType: SOCKET_TYPE });
|
||||
logger.info('NotificationContext', 'Reconnection attempt', { attempts });
|
||||
console.log(`%c[NotificationContext] 🔄 重连中... (第 ${attempts} 次尝试)`, 'color: #FF9800;');
|
||||
});
|
||||
|
||||
// 监听重连失败
|
||||
// ========== 监听重连失败 ==========
|
||||
socket.on('reconnect_failed', () => {
|
||||
logger.error('NotificationContext', 'Socket reconnect_failed');
|
||||
setConnectionStatus(CONNECTION_STATUS.FAILED);
|
||||
console.error('[NotificationContext] ❌ 重连失败');
|
||||
|
||||
toast({
|
||||
title: '连接失败',
|
||||
description: '无法连接到服务器,请检查网络连接',
|
||||
status: 'error',
|
||||
duration: null, // 不自动关闭
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// 监听新事件推送(统一事件名)
|
||||
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
|
||||
socket.on('new_event', (data) => {
|
||||
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('[NotificationContext] 原始事件数据:', data);
|
||||
console.log('[NotificationContext] 事件 ID:', data?.id);
|
||||
console.log('[NotificationContext] 事件标题:', data?.title);
|
||||
console.log('[NotificationContext] 事件类型:', data?.event_type || data?.type);
|
||||
console.log('[NotificationContext] 事件重要性:', data?.importance);
|
||||
|
||||
logger.info('NotificationContext', 'Received new event', data);
|
||||
|
||||
// ⚠️ 防御性检查:确保 ref 已初始化
|
||||
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
|
||||
console.error('%c[NotificationContext] ❌ Ref 未初始化,跳过处理', 'color: #F44336; font-weight: bold;');
|
||||
logger.error('NotificationContext', 'Refs not initialized', {
|
||||
addNotificationRef: !!addNotificationRef.current,
|
||||
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ========== Socket层去重检查 ==========
|
||||
const eventId = data.id || `${data.type}_${data.publishTime}`;
|
||||
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
if (!data.id) {
|
||||
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
|
||||
eventId,
|
||||
eventType: data.type,
|
||||
title: data.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (processedEventIds.current.has(eventId)) {
|
||||
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
|
||||
return; // 重复事件,直接忽略
|
||||
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录已处理的事件ID
|
||||
processedEventIds.current.add(eventId);
|
||||
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
|
||||
|
||||
// 限制Set大小,避免内存泄漏
|
||||
// 限制 Set 大小,避免内存泄漏
|
||||
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
|
||||
const idsArray = Array.from(processedEventIds.current);
|
||||
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
|
||||
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
|
||||
kept: MAX_PROCESSED_IDS
|
||||
kept: MAX_PROCESSED_IDS,
|
||||
});
|
||||
}
|
||||
// ========== Socket层去重检查结束 ==========
|
||||
|
||||
// 使用适配器转换事件格式
|
||||
const notification = adaptEventToNotification(data);
|
||||
addNotification(notification);
|
||||
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
|
||||
console.log('[NotificationContext] 正在转换事件格式...');
|
||||
const notification = adaptEventToNotificationRef.current(data);
|
||||
console.log('[NotificationContext] 转换后的通知对象:', notification);
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的 addNotification 函数
|
||||
console.log('[NotificationContext] 准备添加通知到队列...');
|
||||
addNotificationRef.current(notification);
|
||||
console.log('[NotificationContext] ✅ 通知已添加到队列');
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
});
|
||||
|
||||
// 保留系统通知监听(兼容性)
|
||||
// ========== 监听系统通知(兼容性) ==========
|
||||
socket.on('system_notification', (data) => {
|
||||
logger.info('NotificationContext', 'Received system notification', data);
|
||||
addNotification(data);
|
||||
console.log('[NotificationContext] 📢 收到系统通知:', data);
|
||||
|
||||
if (addNotificationRef.current) {
|
||||
addNotificationRef.current(data);
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ addNotificationRef 未初始化');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('%c[NotificationContext] ✅ All event listeners registered', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('%c[NotificationContext] ✅ 所有监听器已注册(只注册一次)', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// ✅ 第二步: 获取最大重连次数
|
||||
// ========== 获取最大重连次数 ==========
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ✅ 第三步: 调用 socket.connect()
|
||||
console.log('%c[NotificationContext] Step 2: Calling socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
// ========== 启动连接 ==========
|
||||
console.log('%c[NotificationContext] 🔌 调用 socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
socket.connect();
|
||||
console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;');
|
||||
|
||||
// 清理函数
|
||||
// ========== 清理函数(组件卸载时) ==========
|
||||
return () => {
|
||||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||||
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
|
||||
|
||||
// 如果是 mock service,停止推送
|
||||
if (SOCKET_TYPE === 'MOCK') {
|
||||
socket.stopMockPush();
|
||||
// 清理 reconnected 状态定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
clearTimeout(reconnectedTimerRef.current);
|
||||
reconnectedTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 清理所有通知的自动关闭定时器
|
||||
notificationTimers.current.forEach((timerId, id) => {
|
||||
clearTimeout(timerId);
|
||||
logger.info('NotificationContext', 'Cleared timer during cleanup', { id });
|
||||
});
|
||||
notificationTimers.current.clear();
|
||||
|
||||
// 移除所有事件监听器
|
||||
socket.off('connect');
|
||||
socket.off('disconnect');
|
||||
socket.off('connect_error');
|
||||
socket.off('reconnect_failed');
|
||||
socket.off('new_event');
|
||||
socket.off('system_notification');
|
||||
|
||||
// 断开连接
|
||||
socket.disconnect();
|
||||
|
||||
console.log('%c[NotificationContext] ✅ 清理完成', 'color: #4CAF50;');
|
||||
};
|
||||
}, []); // ✅ 空依赖数组,确保只执行一次,避免 React 严格模式重复执行
|
||||
}, []); // ⚠️ 空依赖数组,确保只执行一次
|
||||
|
||||
// ==================== 智能自动重试 ====================
|
||||
|
||||
@@ -720,11 +832,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
|
||||
logger.info('NotificationContext', 'Tab refocused, attempting auto-reconnect');
|
||||
if (SOCKET_TYPE === 'REAL') {
|
||||
socket.reconnect?.();
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
socket.reconnect?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -750,11 +858,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
if (SOCKET_TYPE === 'REAL') {
|
||||
socket.reconnect?.();
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
socket.reconnect?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -786,14 +890,137 @@ export const NotificationProvider = ({ children }) => {
|
||||
const retryConnection = useCallback(() => {
|
||||
logger.info('NotificationContext', 'Manual reconnection triggered');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
|
||||
|
||||
if (SOCKET_TYPE === 'REAL') {
|
||||
socket.reconnect?.();
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
socket.reconnect?.();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 同步浏览器通知权限状态
|
||||
* 场景:
|
||||
* 1. 用户在其他标签页授权后返回
|
||||
* 2. 用户在浏览器设置中修改权限
|
||||
* 3. 页面长时间打开后权限状态变化
|
||||
*/
|
||||
useEffect(() => {
|
||||
const checkPermission = () => {
|
||||
const current = browserNotificationService.getPermissionStatus();
|
||||
if (current !== browserPermission) {
|
||||
logger.info('NotificationContext', 'Browser permission changed', {
|
||||
old: browserPermission,
|
||||
new: current
|
||||
});
|
||||
setBrowserPermission(current);
|
||||
|
||||
// 如果权限被授予,显示成功提示
|
||||
if (current === 'granted' && browserPermission !== 'granted') {
|
||||
toast({
|
||||
title: '桌面通知已开启',
|
||||
description: '您现在可以在后台接收重要通知',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 页面聚焦时检查
|
||||
window.addEventListener('focus', checkPermission);
|
||||
|
||||
// 定期检查(可选,用于捕获浏览器设置中的变化)
|
||||
const intervalId = setInterval(checkPermission, 30000); // 每30秒检查一次
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', checkPermission);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [browserPermission, toast]);
|
||||
|
||||
// 🔧 开发环境调试:暴露方法到 window
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_DEBUG === 'true') {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__TEST_NOTIFICATION__ = {
|
||||
// 手动触发网页通知
|
||||
testWebNotification: (type = 'event_alert', priority = 'normal') => {
|
||||
console.log('%c[Debug] 手动触发网页通知', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
const testData = {
|
||||
id: `test_${Date.now()}`,
|
||||
type: type,
|
||||
priority: priority,
|
||||
title: '🧪 测试网页通知',
|
||||
content: `这是一条测试${type === 'announcement' ? '公告' : type === 'stock_alert' ? '股票' : type === 'event_alert' ? '事件' : '分析'}通知 (优先级: ${priority})`,
|
||||
timestamp: Date.now(),
|
||||
clickable: true,
|
||||
link: '/home',
|
||||
};
|
||||
|
||||
console.log('测试数据:', testData);
|
||||
addNotification(testData);
|
||||
console.log('✅ 通知已添加到队列');
|
||||
},
|
||||
|
||||
// 测试所有类型
|
||||
testAllTypes: () => {
|
||||
console.log('%c[Debug] 测试所有通知类型', 'color: #FF9800; font-weight: bold;');
|
||||
const types = ['announcement', 'stock_alert', 'event_alert', 'analysis_report'];
|
||||
types.forEach((type, i) => {
|
||||
setTimeout(() => {
|
||||
window.__TEST_NOTIFICATION__.testWebNotification(type, 'normal');
|
||||
}, i * 2000); // 每 2 秒一个
|
||||
});
|
||||
},
|
||||
|
||||
// 测试所有优先级
|
||||
testAllPriorities: () => {
|
||||
console.log('%c[Debug] 测试所有优先级', 'color: #FF9800; font-weight: bold;');
|
||||
const priorities = ['normal', 'important', 'urgent'];
|
||||
priorities.forEach((priority, i) => {
|
||||
setTimeout(() => {
|
||||
window.__TEST_NOTIFICATION__.testWebNotification('event_alert', priority);
|
||||
}, i * 2000);
|
||||
});
|
||||
},
|
||||
|
||||
// 帮助
|
||||
help: () => {
|
||||
console.log('\n%c=== 网页通知测试 API ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
console.log('\n%c基础用法:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testWebNotification(type, priority)');
|
||||
console.log('\n%c参数说明:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' type (通知类型):');
|
||||
console.log(' - "announcement" 公告通知(蓝色)');
|
||||
console.log(' - "stock_alert" 股票动向(红色/绿色)');
|
||||
console.log(' - "event_alert" 事件动向(橙色)');
|
||||
console.log(' - "analysis_report" 分析报告(紫色)');
|
||||
console.log('\n priority (优先级):');
|
||||
console.log(' - "normal" 普通(15秒自动关闭)');
|
||||
console.log(' - "important" 重要(30秒自动关闭)');
|
||||
console.log(' - "urgent" 紧急(不自动关闭)');
|
||||
console.log('\n%c示例:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log(' // 测试紧急事件通知');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testWebNotification("event_alert", "urgent")');
|
||||
console.log('\n // 测试所有类型');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testAllTypes()');
|
||||
console.log('\n // 测试所有优先级');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testAllPriorities()');
|
||||
console.log('\n');
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[NotificationContext] 🔧 调试 API 已加载: window.__TEST_NOTIFICATION__');
|
||||
console.log('[NotificationContext] 💡 使用 window.__TEST_NOTIFICATION__.help() 查看帮助');
|
||||
}
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
delete window.__TEST_NOTIFICATION__;
|
||||
}
|
||||
};
|
||||
}, [addNotification]); // 依赖 addNotification 函数
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
isConnected,
|
||||
|
||||
253
src/devtools/apiDebugger.js
Normal file
253
src/devtools/apiDebugger.js
Normal file
@@ -0,0 +1,253 @@
|
||||
// src/debug/apiDebugger.js
|
||||
/**
|
||||
* API 调试工具
|
||||
* 生产环境临时调试使用,后期可整体删除 src/debug/ 目录
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
class ApiDebugger {
|
||||
constructor() {
|
||||
this.requestLog = [];
|
||||
this.maxLogSize = 100;
|
||||
this.isLogging = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Axios 拦截器
|
||||
*/
|
||||
init() {
|
||||
// 请求拦截器
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
if (this.isLogging) {
|
||||
const logEntry = {
|
||||
type: 'request',
|
||||
timestamp: new Date().toISOString(),
|
||||
method: config.method.toUpperCase(),
|
||||
url: config.url,
|
||||
baseURL: config.baseURL,
|
||||
fullURL: this._getFullURL(config),
|
||||
headers: config.headers,
|
||||
data: config.data,
|
||||
params: config.params,
|
||||
};
|
||||
|
||||
this._addLog(logEntry);
|
||||
|
||||
console.log(
|
||||
`%c[API Request] ${logEntry.method} ${logEntry.fullURL}`,
|
||||
'color: #2196F3; font-weight: bold;',
|
||||
{
|
||||
headers: config.headers,
|
||||
data: config.data,
|
||||
params: config.params,
|
||||
}
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[API Request Error]', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
axios.interceptors.response.use(
|
||||
(response) => {
|
||||
if (this.isLogging) {
|
||||
const logEntry = {
|
||||
type: 'response',
|
||||
timestamp: new Date().toISOString(),
|
||||
method: response.config.method.toUpperCase(),
|
||||
url: response.config.url,
|
||||
fullURL: this._getFullURL(response.config),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
data: response.data,
|
||||
};
|
||||
|
||||
this._addLog(logEntry);
|
||||
|
||||
console.log(
|
||||
`%c[API Response] ${logEntry.method} ${logEntry.fullURL} - ${logEntry.status}`,
|
||||
'color: #4CAF50; font-weight: bold;',
|
||||
{
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
headers: response.headers,
|
||||
}
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (this.isLogging) {
|
||||
const logEntry = {
|
||||
type: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
method: error.config?.method?.toUpperCase() || 'UNKNOWN',
|
||||
url: error.config?.url || 'UNKNOWN',
|
||||
fullURL: error.config ? this._getFullURL(error.config) : 'UNKNOWN',
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
message: error.message,
|
||||
data: error.response?.data,
|
||||
};
|
||||
|
||||
this._addLog(logEntry);
|
||||
|
||||
console.error(
|
||||
`%c[API Error] ${logEntry.method} ${logEntry.fullURL}`,
|
||||
'color: #F44336; font-weight: bold;',
|
||||
{
|
||||
status: error.response?.status,
|
||||
message: error.message,
|
||||
data: error.response?.data,
|
||||
}
|
||||
);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
console.log('%c[API Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整 URL
|
||||
*/
|
||||
_getFullURL(config) {
|
||||
const baseURL = config.baseURL || '';
|
||||
const url = config.url || '';
|
||||
const fullURL = baseURL + url;
|
||||
|
||||
// 添加查询参数
|
||||
if (config.params) {
|
||||
const params = new URLSearchParams(config.params).toString();
|
||||
return params ? `${fullURL}?${params}` : fullURL;
|
||||
}
|
||||
|
||||
return fullURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加日志
|
||||
*/
|
||||
_addLog(entry) {
|
||||
this.requestLog.unshift(entry);
|
||||
if (this.requestLog.length > this.maxLogSize) {
|
||||
this.requestLog = this.requestLog.slice(0, this.maxLogSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有日志
|
||||
*/
|
||||
getLogs(type = 'all') {
|
||||
if (type === 'all') {
|
||||
return this.requestLog;
|
||||
}
|
||||
return this.requestLog.filter((log) => log.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.requestLog = [];
|
||||
console.log('[API Debugger] Logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志为 JSON
|
||||
*/
|
||||
exportLogs() {
|
||||
const blob = new Blob([JSON.stringify(this.requestLog, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `api-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[API Debugger] Logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印日志统计
|
||||
*/
|
||||
printStats() {
|
||||
const stats = {
|
||||
total: this.requestLog.length,
|
||||
requests: this.requestLog.filter((log) => log.type === 'request').length,
|
||||
responses: this.requestLog.filter((log) => log.type === 'response').length,
|
||||
errors: this.requestLog.filter((log) => log.type === 'error').length,
|
||||
};
|
||||
|
||||
console.table(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动发送 API 请求(测试用)
|
||||
*/
|
||||
async testRequest(method, endpoint, data = null, config = {}) {
|
||||
const apiBase = getApiBase();
|
||||
const url = `${apiBase}${endpoint}`;
|
||||
|
||||
console.log(`[API Debugger] Testing ${method.toUpperCase()} ${url}`);
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
...config,
|
||||
});
|
||||
|
||||
console.log('[API Debugger] Test succeeded:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[API Debugger] Test failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启/关闭日志记录
|
||||
*/
|
||||
toggleLogging(enabled) {
|
||||
this.isLogging = enabled;
|
||||
console.log(`[API Debugger] Logging ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的错误
|
||||
*/
|
||||
getRecentErrors(count = 10) {
|
||||
return this.requestLog.filter((log) => log.type === 'error').slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 URL 过滤日志
|
||||
*/
|
||||
getLogsByURL(urlPattern) {
|
||||
return this.requestLog.filter((log) => log.url && log.url.includes(urlPattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按状态码过滤日志
|
||||
*/
|
||||
getLogsByStatus(status) {
|
||||
return this.requestLog.filter((log) => log.status === status);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const apiDebugger = new ApiDebugger();
|
||||
export default apiDebugger;
|
||||
271
src/devtools/index.js
Normal file
271
src/devtools/index.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// src/debug/index.js
|
||||
/**
|
||||
* 调试工具统一入口
|
||||
*
|
||||
* 使用方法:
|
||||
* 1. 开启调试: 在 .env.production 中设置 REACT_APP_ENABLE_DEBUG=true
|
||||
* 2. 使用控制台命令: window.__DEBUG__.api.getLogs()
|
||||
* 3. 后期移除: 删除整个 src/debug/ 目录 + 从 src/index.js 移除导入
|
||||
*
|
||||
* 全局 API:
|
||||
* - window.__DEBUG__ - 调试 API 主对象
|
||||
* - window.__DEBUG__.api - API 调试工具
|
||||
* - window.__DEBUG__.notification - 通知调试工具
|
||||
* - window.__DEBUG__.socket - Socket 调试工具
|
||||
* - window.__DEBUG__.help() - 显示帮助信息
|
||||
* - window.__DEBUG__.exportAll() - 导出所有日志
|
||||
*/
|
||||
|
||||
import { apiDebugger } from './apiDebugger';
|
||||
import { notificationDebugger } from './notificationDebugger';
|
||||
import { socketDebugger } from './socketDebugger';
|
||||
|
||||
class DebugToolkit {
|
||||
constructor() {
|
||||
this.api = apiDebugger;
|
||||
this.notification = notificationDebugger;
|
||||
this.socket = socketDebugger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有调试工具
|
||||
*/
|
||||
init() {
|
||||
console.log(
|
||||
'%c╔════════════════════════════════════════════════════════════════╗',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%c║ 🔧 调试模式已启用 (Debug Mode Enabled) ║',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%c╚════════════════════════════════════════════════════════════════╝',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
console.log('');
|
||||
|
||||
// 初始化各个调试工具
|
||||
this.api.init();
|
||||
this.notification.init();
|
||||
this.socket.init();
|
||||
|
||||
// 暴露到全局
|
||||
window.__DEBUG__ = this;
|
||||
|
||||
// 打印帮助信息
|
||||
this._printWelcome();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印欢迎信息
|
||||
*/
|
||||
_printWelcome() {
|
||||
console.log('%c📚 调试工具使用指南:', 'color: #2196F3; font-weight: bold; font-size: 14px;');
|
||||
console.log('');
|
||||
console.log('%c1️⃣ API 调试:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' __DEBUG__.api.getLogs() - 获取所有 API 日志');
|
||||
console.log(' __DEBUG__.api.getRecentErrors() - 获取最近的错误');
|
||||
console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志');
|
||||
console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求');
|
||||
console.log('');
|
||||
console.log('%c2️⃣ 通知调试:', 'color: #9C27B0; font-weight: bold;');
|
||||
console.log(' __DEBUG__.notification.getLogs() - 获取所有通知日志');
|
||||
console.log(' __DEBUG__.notification.forceNotification() - 发送测试浏览器通知');
|
||||
console.log(' __DEBUG__.notification.testWebNotification(type, priority) - 测试网页通知 🆕');
|
||||
console.log(' __DEBUG__.notification.testAllNotificationTypes() - 测试所有类型 🆕');
|
||||
console.log(' __DEBUG__.notification.testAllNotificationPriorities() - 测试所有优先级 🆕');
|
||||
console.log(' __DEBUG__.notification.checkPermission() - 检查通知权限');
|
||||
console.log(' __DEBUG__.notification.exportLogs() - 导出通知日志');
|
||||
console.log('');
|
||||
console.log('%c3️⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
|
||||
console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志');
|
||||
console.log(' __DEBUG__.socket.getStatus() - 获取连接状态');
|
||||
console.log(' __DEBUG__.socket.reconnect() - 手动重连');
|
||||
console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志');
|
||||
console.log('');
|
||||
console.log('%c4️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log(' __DEBUG__.help() - 显示帮助信息');
|
||||
console.log(' __DEBUG__.exportAll() - 导出所有日志');
|
||||
console.log(' __DEBUG__.printStats() - 打印所有统计信息');
|
||||
console.log(' __DEBUG__.clearAll() - 清空所有日志');
|
||||
console.log('');
|
||||
console.log(
|
||||
'%c⚠️ 警告: 调试模式会记录所有 API 请求和响应,请勿在生产环境长期开启!',
|
||||
'color: #F44336; font-weight: bold;'
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示帮助信息
|
||||
*/
|
||||
help() {
|
||||
this._printWelcome();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有日志
|
||||
*/
|
||||
exportAll() {
|
||||
console.log('[Debug Toolkit] Exporting all logs...');
|
||||
|
||||
const allLogs = {
|
||||
timestamp: new Date().toISOString(),
|
||||
api: this.api.getLogs(),
|
||||
notification: this.notification.getLogs(),
|
||||
socket: this.socket.getLogs(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(allLogs, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `debug-all-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('[Debug Toolkit] ✅ All logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印所有统计信息
|
||||
*/
|
||||
printStats() {
|
||||
console.log('\n%c=== 📊 调试统计信息 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;');
|
||||
const apiStats = this.api.printStats();
|
||||
|
||||
console.log('\n%c[通知统计]', 'color: #9C27B0; font-weight: bold;');
|
||||
const notificationStats = this.notification.printStats();
|
||||
|
||||
console.log('\n%c[Socket 统计]', 'color: #00BCD4; font-weight: bold;');
|
||||
const socketStats = this.socket.printStats();
|
||||
|
||||
return {
|
||||
api: apiStats,
|
||||
notification: notificationStats,
|
||||
socket: socketStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有日志
|
||||
*/
|
||||
clearAll() {
|
||||
console.log('[Debug Toolkit] Clearing all logs...');
|
||||
this.api.clearLogs();
|
||||
this.notification.clearLogs();
|
||||
this.socket.clearLogs();
|
||||
console.log('[Debug Toolkit] ✅ All logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速诊断(检查所有系统状态)
|
||||
*/
|
||||
diagnose() {
|
||||
console.log('\n%c=== 🔍 系统诊断 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
|
||||
// 1. Socket 状态
|
||||
console.log('\n%c[1/3] Socket 状态', 'color: #00BCD4; font-weight: bold;');
|
||||
const socketStatus = this.socket.getStatus();
|
||||
|
||||
// 2. 通知权限
|
||||
console.log('\n%c[2/3] 通知权限', 'color: #9C27B0; font-weight: bold;');
|
||||
const notificationStatus = this.notification.checkPermission();
|
||||
|
||||
// 3. API 错误
|
||||
console.log('\n%c[3/3] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
|
||||
const recentErrors = this.api.getRecentErrors(5);
|
||||
if (recentErrors.length > 0) {
|
||||
console.table(
|
||||
recentErrors.map((err) => ({
|
||||
时间: err.timestamp,
|
||||
方法: err.method,
|
||||
URL: err.url,
|
||||
状态码: err.status,
|
||||
错误信息: err.message,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
console.log('✅ 没有 API 错误');
|
||||
}
|
||||
|
||||
// 4. 汇总报告
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
socket: socketStatus,
|
||||
notification: notificationStatus,
|
||||
apiErrors: recentErrors.length,
|
||||
};
|
||||
|
||||
console.log('\n%c=== 诊断报告 ===', 'color: #4CAF50; font-weight: bold;');
|
||||
console.table(report);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能监控
|
||||
*/
|
||||
performance() {
|
||||
console.log('\n%c=== ⚡ 性能监控 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
|
||||
// 计算 API 平均响应时间
|
||||
const apiLogs = this.api.getLogs();
|
||||
const responseTimes = [];
|
||||
|
||||
for (let i = 0; i < apiLogs.length - 1; i++) {
|
||||
const log = apiLogs[i];
|
||||
const prevLog = apiLogs[i + 1];
|
||||
|
||||
if (
|
||||
log.type === 'response' &&
|
||||
prevLog.type === 'request' &&
|
||||
log.url === prevLog.url
|
||||
) {
|
||||
const responseTime =
|
||||
new Date(log.timestamp).getTime() - new Date(prevLog.timestamp).getTime();
|
||||
responseTimes.push({
|
||||
url: log.url,
|
||||
method: log.method,
|
||||
time: responseTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (responseTimes.length > 0) {
|
||||
const avgTime =
|
||||
responseTimes.reduce((sum, item) => sum + item.time, 0) / responseTimes.length;
|
||||
const maxTime = Math.max(...responseTimes.map((item) => item.time));
|
||||
const minTime = Math.min(...responseTimes.map((item) => item.time));
|
||||
|
||||
console.log('API 响应时间统计:');
|
||||
console.table({
|
||||
平均响应时间: `${avgTime.toFixed(2)}ms`,
|
||||
最快响应: `${minTime}ms`,
|
||||
最慢响应: `${maxTime}ms`,
|
||||
请求总数: responseTimes.length,
|
||||
});
|
||||
|
||||
// 显示最慢的 5 个请求
|
||||
console.log('\n最慢的 5 个请求:');
|
||||
const slowest = responseTimes.sort((a, b) => b.time - a.time).slice(0, 5);
|
||||
console.table(
|
||||
slowest.map((item) => ({
|
||||
方法: item.method,
|
||||
URL: item.url,
|
||||
响应时间: `${item.time}ms`,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
console.log('暂无性能数据');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const debugToolkit = new DebugToolkit();
|
||||
export default debugToolkit;
|
||||
204
src/devtools/notificationDebugger.js
Normal file
204
src/devtools/notificationDebugger.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/debug/notificationDebugger.js
|
||||
/**
|
||||
* 通知系统调试工具
|
||||
* 扩展现有的 window.__NOTIFY_DEBUG__,添加更多生产环境调试能力
|
||||
*/
|
||||
|
||||
import { browserNotificationService } from '@services/browserNotificationService';
|
||||
|
||||
class NotificationDebugger {
|
||||
constructor() {
|
||||
this.eventLog = [];
|
||||
this.maxLogSize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化调试工具
|
||||
*/
|
||||
init() {
|
||||
console.log('%c[Notification Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录通知事件
|
||||
*/
|
||||
logEvent(eventType, data) {
|
||||
const logEntry = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
this.eventLog.unshift(logEntry);
|
||||
if (this.eventLog.length > this.maxLogSize) {
|
||||
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`%c[Notification Event] ${eventType}`,
|
||||
'color: #9C27B0; font-weight: bold;',
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件日志
|
||||
*/
|
||||
getLogs() {
|
||||
return this.eventLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.eventLog = [];
|
||||
console.log('[Notification Debugger] Logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志
|
||||
*/
|
||||
exportLogs() {
|
||||
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `notification-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[Notification Debugger] Logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制发送浏览器通知(测试用)
|
||||
*/
|
||||
forceNotification(options = {}) {
|
||||
const defaultOptions = {
|
||||
title: '🧪 测试通知',
|
||||
body: `测试时间: ${new Date().toLocaleString()}`,
|
||||
tag: `test_${Date.now()}`,
|
||||
requireInteraction: false,
|
||||
autoClose: 5000,
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
|
||||
console.log('[Notification Debugger] Sending test notification:', finalOptions);
|
||||
|
||||
const notification = browserNotificationService.sendNotification(finalOptions);
|
||||
|
||||
if (notification) {
|
||||
console.log('[Notification Debugger] ✅ Notification sent successfully');
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ Failed to send notification');
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查通知权限状态
|
||||
*/
|
||||
checkPermission() {
|
||||
const permission = browserNotificationService.getPermissionStatus();
|
||||
const isSupported = browserNotificationService.isSupported();
|
||||
|
||||
const status = {
|
||||
supported: isSupported,
|
||||
permission,
|
||||
canSend: isSupported && permission === 'granted',
|
||||
};
|
||||
|
||||
console.table(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
*/
|
||||
async requestPermission() {
|
||||
console.log('[Notification Debugger] Requesting notification permission...');
|
||||
const result = await browserNotificationService.requestPermission();
|
||||
console.log(`[Notification Debugger] Permission result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印事件统计
|
||||
*/
|
||||
printStats() {
|
||||
const stats = {
|
||||
total: this.eventLog.length,
|
||||
byType: {},
|
||||
};
|
||||
|
||||
this.eventLog.forEach((log) => {
|
||||
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log('=== Notification Stats ===');
|
||||
console.table(stats.byType);
|
||||
console.log(`Total events: ${stats.total}`);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型过滤日志
|
||||
*/
|
||||
getLogsByType(eventType) {
|
||||
return this.eventLog.filter((log) => log.type === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的事件
|
||||
*/
|
||||
getRecentEvents(count = 10) {
|
||||
return this.eventLog.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试网页通知(需要 window.__TEST_NOTIFICATION__ 可用)
|
||||
*/
|
||||
testWebNotification(type = 'event_alert', priority = 'normal') {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
console.log('[Notification Debugger] 调用测试 API');
|
||||
window.__TEST_NOTIFICATION__.testWebNotification(type, priority);
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
console.error('💡 请确保:');
|
||||
console.error(' 1. REACT_APP_ENABLE_DEBUG=true');
|
||||
console.error(' 2. NotificationContext 已加载');
|
||||
console.error(' 3. 页面已刷新');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有通知类型
|
||||
*/
|
||||
testAllNotificationTypes() {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
window.__TEST_NOTIFICATION__.testAllTypes();
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有优先级
|
||||
*/
|
||||
testAllNotificationPriorities() {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
window.__TEST_NOTIFICATION__.testAllPriorities();
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const notificationDebugger = new NotificationDebugger();
|
||||
export default notificationDebugger;
|
||||
194
src/devtools/socketDebugger.js
Normal file
194
src/devtools/socketDebugger.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// src/debug/socketDebugger.js
|
||||
/**
|
||||
* Socket 调试工具
|
||||
* 扩展现有的 window.__SOCKET_DEBUG__,添加更多生产环境调试能力
|
||||
*/
|
||||
|
||||
import { socket } from '@services/socket';
|
||||
|
||||
class SocketDebugger {
|
||||
constructor() {
|
||||
this.eventLog = [];
|
||||
this.maxLogSize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化调试工具
|
||||
*/
|
||||
init() {
|
||||
// 监听所有 Socket 事件
|
||||
this._attachEventListeners();
|
||||
console.log('%c[Socket Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
|
||||
}
|
||||
|
||||
/**
|
||||
* 附加事件监听器
|
||||
*/
|
||||
_attachEventListeners() {
|
||||
const events = [
|
||||
'connect',
|
||||
'disconnect',
|
||||
'connect_error',
|
||||
'reconnect',
|
||||
'reconnect_failed',
|
||||
'new_event',
|
||||
'system_notification',
|
||||
];
|
||||
|
||||
events.forEach((event) => {
|
||||
socket.on(event, (data) => {
|
||||
this.logEvent(event, data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 Socket 事件
|
||||
*/
|
||||
logEvent(eventType, data) {
|
||||
const logEntry = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
this.eventLog.unshift(logEntry);
|
||||
if (this.eventLog.length > this.maxLogSize) {
|
||||
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`%c[Socket Event] ${eventType}`,
|
||||
'color: #00BCD4; font-weight: bold;',
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件日志
|
||||
*/
|
||||
getLogs() {
|
||||
return this.eventLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.eventLog = [];
|
||||
console.log('[Socket Debugger] Logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志
|
||||
*/
|
||||
exportLogs() {
|
||||
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `socket-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[Socket Debugger] Logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
getStatus() {
|
||||
const status = {
|
||||
connected: socket.connected || false,
|
||||
type: window.SOCKET_TYPE || 'UNKNOWN',
|
||||
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
|
||||
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
|
||||
};
|
||||
|
||||
console.table(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发连接
|
||||
*/
|
||||
connect() {
|
||||
console.log('[Socket Debugger] Manually connecting...');
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
console.log('[Socket Debugger] Manually disconnecting...');
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重连
|
||||
*/
|
||||
reconnect() {
|
||||
console.log('[Socket Debugger] Manually reconnecting...');
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
socket.connect();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送测试事件
|
||||
*/
|
||||
emitTest(eventName, data = {}) {
|
||||
console.log(`[Socket Debugger] Emitting test event: ${eventName}`, data);
|
||||
socket.emit(eventName, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印事件统计
|
||||
*/
|
||||
printStats() {
|
||||
const stats = {
|
||||
total: this.eventLog.length,
|
||||
byType: {},
|
||||
};
|
||||
|
||||
this.eventLog.forEach((log) => {
|
||||
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log('=== Socket Stats ===');
|
||||
console.table(stats.byType);
|
||||
console.log(`Total events: ${stats.total}`);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型过滤日志
|
||||
*/
|
||||
getLogsByType(eventType) {
|
||||
return this.eventLog.filter((log) => log.type === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的事件
|
||||
*/
|
||||
getRecentEvents(count = 10) {
|
||||
return this.eventLog.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误事件
|
||||
*/
|
||||
getErrors() {
|
||||
return this.eventLog.filter(
|
||||
(log) => log.type === 'connect_error' || log.type === 'reconnect_failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const socketDebugger = new SocketDebugger();
|
||||
export default socketDebugger;
|
||||
72
src/index.js
72
src/index.js
@@ -2,15 +2,33 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
// 本地引入 Leaflet 样式,替代不稳定的 CDN 外链
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
// 导入 Tailwind CSS 和 Brainwave 样式
|
||||
import './styles/brainwave.css';
|
||||
import './styles/brainwave-colors.css';
|
||||
|
||||
// 导入 Bytedesk 客服系统 z-index 覆盖样式(必须在所有样式之后导入)
|
||||
import './styles/bytedesk-override.css';
|
||||
|
||||
// Import the main App component
|
||||
import App from './App';
|
||||
|
||||
// 导入通知服务并挂载到 window(用于调试)
|
||||
import { browserNotificationService } from './services/browserNotificationService';
|
||||
window.browserNotificationService = browserNotificationService;
|
||||
|
||||
// 🔧 条件导入调试工具(生产环境可选)
|
||||
// 开启方式: 在 .env 文件中设置 REACT_APP_ENABLE_DEBUG=true
|
||||
// 移除方式: 删除此段代码 + 删除 src/devtools/ 目录
|
||||
if (process.env.REACT_APP_ENABLE_DEBUG === 'true') {
|
||||
import('./devtools').then(({ debugToolkit }) => {
|
||||
debugToolkit.init();
|
||||
console.log(
|
||||
'%c✅ 调试工具已加载!使用 window.__DEBUG__.help() 查看命令',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 Service Worker(用于支持浏览器通知)
|
||||
function registerServiceWorker() {
|
||||
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
|
||||
@@ -29,24 +47,64 @@ function registerServiceWorker() {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then((registration) => {
|
||||
console.log('[App] Service Worker registered successfully:', registration.scope);
|
||||
console.log('[App] ✅ Service Worker 注册成功');
|
||||
console.log('[App] Scope:', registration.scope);
|
||||
|
||||
// 监听更新
|
||||
// 检查当前激活状态
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log('[App] ✅ Service Worker 已激活并控制页面');
|
||||
} else {
|
||||
console.log('[App] ⏳ Service Worker 已注册,等待激活...');
|
||||
console.log('[App] 💡 刷新页面以激活 Service Worker');
|
||||
|
||||
// 监听 controller 变化(Service Worker 激活后触发)
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('[App] ✅ Service Worker 控制器已更新');
|
||||
});
|
||||
}
|
||||
|
||||
// 监听 Service Worker 更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
console.log('[App] Service Worker update found');
|
||||
console.log('[App] 🔄 发现 Service Worker 更新');
|
||||
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
console.log(`[App] Service Worker 状态: ${newWorker.state}`);
|
||||
if (newWorker.state === 'activated') {
|
||||
console.log('[App] Service Worker activated');
|
||||
console.log('[App] ✅ Service Worker 已激活');
|
||||
|
||||
// 如果有旧的 Service Worker 在控制页面,提示用户刷新
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log('[App] 💡 Service Worker 已更新,建议刷新页面');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[App] Service Worker registration failed:', error);
|
||||
console.error('[App] ❌ Service Worker 注册失败');
|
||||
console.error('[App] 错误类型:', error.name);
|
||||
console.error('[App] 错误信息:', error.message);
|
||||
console.error('[App] 完整错误:', error);
|
||||
|
||||
// 额外检查:验证文件是否可访问
|
||||
fetch('/service-worker.js', { method: 'HEAD' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
console.error('[App] Service Worker 文件存在但注册失败');
|
||||
console.error('[App] 💡 可能的原因:');
|
||||
console.error('[App] 1. Service Worker 文件有语法错误');
|
||||
console.error('[App] 2. 浏览器不支持某些 Service Worker 特性');
|
||||
console.error('[App] 3. HTTPS 证书问题(Service Worker 需要 HTTPS)');
|
||||
} else {
|
||||
console.error('[App] Service Worker 文件不存在(HTTP', response.status, ')');
|
||||
}
|
||||
})
|
||||
.catch(fetchError => {
|
||||
console.error('[App] 无法访问 Service Worker 文件:', fetchError.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -8,14 +8,14 @@ import RiskDisclaimer from '../components/RiskDisclaimer';
|
||||
*/
|
||||
const AppFooter = () => {
|
||||
return (
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={2}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={2}>
|
||||
<VStack spacing={1}>
|
||||
<RiskDisclaimer />
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<HStack spacing={1} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
|
||||
@@ -217,7 +217,7 @@ export const REVENUE_EVENTS = {
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SPECIAL EVENTS (特殊事件) - Errors, performance, chatbot
|
||||
// SPECIAL EVENTS (特殊事件) - Errors, performance
|
||||
// ============================================================================
|
||||
export const SPECIAL_EVENTS = {
|
||||
// Errors
|
||||
@@ -229,13 +229,6 @@ export const SPECIAL_EVENTS = {
|
||||
PAGE_LOAD_TIME: 'Page Load Time',
|
||||
API_RESPONSE_TIME: 'API Response Time',
|
||||
|
||||
// Chatbot (Dify)
|
||||
CHATBOT_OPENED: 'Chatbot Opened',
|
||||
CHATBOT_CLOSED: 'Chatbot Closed',
|
||||
CHATBOT_MESSAGE_SENT: 'Chatbot Message Sent',
|
||||
CHATBOT_MESSAGE_RECEIVED: 'Chatbot Message Received',
|
||||
CHATBOT_FEEDBACK_PROVIDED: 'Chatbot Feedback Provided',
|
||||
|
||||
// Scroll depth
|
||||
SCROLL_DEPTH_25: 'Scroll Depth 25%',
|
||||
SCROLL_DEPTH_50: 'Scroll Depth 50%',
|
||||
|
||||
@@ -1080,14 +1080,65 @@ export const eventHandlers = [
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
const importance = importanceLevels[Math.floor(Math.random() * importanceLevels.length)];
|
||||
const title = eventTitles[i % eventTitles.length];
|
||||
|
||||
// 带引用来源的研报数据
|
||||
const researchReports = [
|
||||
{
|
||||
author: '中信证券',
|
||||
report_title: `${title}深度研究报告`,
|
||||
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 10) * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
author: '国泰君安',
|
||||
report_title: `行业专题:${title}影响分析`,
|
||||
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 15) * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
author: '华泰证券',
|
||||
report_title: `${title}投资机会深度解析`,
|
||||
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 20) * 24 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
// 生成带引用标记的content(data结构)
|
||||
const contentWithCitations = {
|
||||
data: [
|
||||
{
|
||||
query_part: `${title}的详细描述。该事件对相关产业链产生重要影响【1】,市场关注度高,相关概念股表现活跃。`,
|
||||
sentences: `根据券商研报分析,${title}将推动相关产业链快速发展【2】。预计未来${Math.floor(Math.random() * 2 + 1)}年内,相关企业营收增速有望达到${Math.floor(Math.random() * 30 + 20)}%以上【3】。该事件的影响范围广泛,涉及多个细分领域,投资机会显著。`,
|
||||
match_score: importance >= 4 ? '好' : (importance >= 2 ? '中' : '一般'),
|
||||
author: researchReports[0].author,
|
||||
declare_date: researchReports[0].declare_date,
|
||||
report_title: researchReports[0].report_title
|
||||
},
|
||||
{
|
||||
query_part: `市场分析师认为,该事件将带动产业链上下游企业协同发展【2】,形成良性循环。`,
|
||||
sentences: `从产业趋势来看,相关板块估值仍处于合理区间,具备较高的安全边际。机构投资者持续加仓相关标的,显示出对长期发展前景的看好。`,
|
||||
match_score: importance >= 3 ? '好' : '中',
|
||||
author: researchReports[1].author,
|
||||
declare_date: researchReports[1].declare_date,
|
||||
report_title: researchReports[1].report_title
|
||||
},
|
||||
{
|
||||
query_part: `根据行业数据显示,受此事件影响,相关企业订单量同比增长${Math.floor(Math.random() * 40 + 30)}%【3】。`,
|
||||
sentences: `行业景气度持续提升,龙头企业凭借技术优势和规模效应,市场份额有望进一步扩大。建议关注产业链核心环节的投资机会。`,
|
||||
match_score: '好',
|
||||
author: researchReports[2].author,
|
||||
declare_date: researchReports[2].declare_date,
|
||||
report_title: researchReports[2].report_title
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
events.push({
|
||||
id: `hist_event_${i + 1}`,
|
||||
title: eventTitles[i % eventTitles.length],
|
||||
description: `${eventTitles[i % eventTitles.length]}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`,
|
||||
title: title,
|
||||
content: contentWithCitations, // 升级版本:带引用来源的data结构
|
||||
description: `${title}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`, // 降级兼容
|
||||
date: date.toISOString().split('T')[0],
|
||||
importance: importance,
|
||||
similarity: parseFloat((Math.random() * 0.3 + 0.7).toFixed(2)), // 0.7-1.0
|
||||
similarity: Math.floor(Math.random() * 10) + 1, // 1-10
|
||||
impact_sectors: [
|
||||
['半导体', '芯片设计', 'EDA'],
|
||||
['新能源汽车', '锂电池', '充电桩'],
|
||||
|
||||
@@ -35,6 +35,9 @@ export const lazyComponents = {
|
||||
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')),
|
||||
|
||||
// Agent模块
|
||||
AgentChat: React.lazy(() => import('../views/AgentChat')),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,4 +62,5 @@ export const {
|
||||
ForecastReport,
|
||||
FinancialPanorama,
|
||||
MarketDataView,
|
||||
AgentChat,
|
||||
} = lazyComponents;
|
||||
|
||||
@@ -149,6 +149,18 @@ export const routeConfig = [
|
||||
description: '实时市场数据'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Agent模块 ====================
|
||||
{
|
||||
path: 'agent-chat',
|
||||
component: lazyComponents.AgentChat,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '价小前投研',
|
||||
description: '北京价值前沿科技公司的AI投研聊天助手'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,14 @@ class BrowserNotificationService {
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有通知权限
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasPermission() {
|
||||
return this.isSupported() && Notification.permission === 'granted';
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
* @returns {Promise<string>} 权限状态
|
||||
@@ -77,57 +85,99 @@ class BrowserNotificationService {
|
||||
data = {},
|
||||
autoClose = 0,
|
||||
}) {
|
||||
// 详细日志:检查支持状态
|
||||
if (!this.isSupported()) {
|
||||
logger.warn('browserNotificationService', 'Notifications not supported');
|
||||
console.warn('[browserNotificationService] ❌ 浏览器不支持通知 API');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.permission !== 'granted') {
|
||||
logger.warn('browserNotificationService', 'Permission not granted');
|
||||
// 详细日志:检查权限状态
|
||||
const currentPermission = Notification.permission;
|
||||
console.log('[browserNotificationService] 当前权限状态:', currentPermission);
|
||||
|
||||
if (currentPermission !== 'granted') {
|
||||
logger.warn('browserNotificationService', 'Permission not granted', { permission: currentPermission });
|
||||
console.warn(`[browserNotificationService] ❌ 权限未授予: ${currentPermission}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[browserNotificationService] ✅ 准备发送通知:', { title, body, tag, requireInteraction, autoClose });
|
||||
|
||||
try {
|
||||
// 关闭相同 tag 的旧通知
|
||||
if (tag && this.activeNotifications.has(tag)) {
|
||||
const oldNotification = this.activeNotifications.get(tag);
|
||||
oldNotification.close();
|
||||
console.log('[browserNotificationService] 关闭旧通知:', tag);
|
||||
}
|
||||
|
||||
// 创建通知
|
||||
const finalTag = tag || `notification_${Date.now()}`;
|
||||
console.log('[browserNotificationService] 创建通知对象...');
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon,
|
||||
badge: '/badge.png',
|
||||
tag: tag || `notification_${Date.now()}`,
|
||||
tag: finalTag,
|
||||
requireInteraction,
|
||||
data,
|
||||
silent: false, // 允许声音
|
||||
});
|
||||
|
||||
console.log('[browserNotificationService] ✅ 通知对象已创建:', notification);
|
||||
|
||||
// 存储通知引用
|
||||
if (tag) {
|
||||
this.activeNotifications.set(tag, notification);
|
||||
console.log('[browserNotificationService] 通知已存储到活跃列表');
|
||||
}
|
||||
|
||||
// 自动关闭
|
||||
if (autoClose > 0 && !requireInteraction) {
|
||||
console.log(`[browserNotificationService] 设置自动关闭: ${autoClose}ms`);
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
console.log('[browserNotificationService] 通知已自动关闭');
|
||||
}, autoClose);
|
||||
}
|
||||
|
||||
// 通知关闭时清理引用
|
||||
notification.onclose = () => {
|
||||
console.log('[browserNotificationService] 通知被关闭:', finalTag);
|
||||
if (tag) {
|
||||
this.activeNotifications.delete(tag);
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('browserNotificationService', 'Notification sent', { title, tag });
|
||||
// 通知点击事件
|
||||
notification.onclick = (event) => {
|
||||
console.log('[browserNotificationService] 通知被点击:', finalTag, data);
|
||||
};
|
||||
|
||||
// 通知显示事件
|
||||
notification.onshow = () => {
|
||||
console.log('[browserNotificationService] ✅ 通知已显示:', finalTag);
|
||||
};
|
||||
|
||||
// 通知错误事件
|
||||
notification.onerror = (error) => {
|
||||
console.error('[browserNotificationService] ❌ 通知显示错误:', error);
|
||||
};
|
||||
|
||||
logger.info('browserNotificationService', 'Notification sent', { title, tag: finalTag });
|
||||
console.log('[browserNotificationService] ✅ 通知发送成功!');
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
logger.error('browserNotificationService', 'sendNotification', error);
|
||||
console.error('[browserNotificationService] ❌ 发送通知时发生错误:', error);
|
||||
console.error('[browserNotificationService] 错误详情:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
278
src/services/llmService.js
Normal file
278
src/services/llmService.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// src/services/llmService.js
|
||||
// LLM服务层 - 集成AI模型进行对话和工具调用
|
||||
|
||||
import axios from 'axios';
|
||||
import { mcpService } from './mcpService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* LLM服务配置
|
||||
*/
|
||||
const LLM_CONFIG = {
|
||||
// 可以使用 OpenAI、Claude、通义千问等
|
||||
provider: 'openai', // 或 'claude', 'qwen'
|
||||
apiKey: process.env.REACT_APP_OPENAI_API_KEY || '',
|
||||
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
model: 'gpt-4o-mini', // 更便宜的模型
|
||||
};
|
||||
|
||||
/**
|
||||
* LLM服务类
|
||||
*/
|
||||
class LLMService {
|
||||
constructor() {
|
||||
this.conversationHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建系统提示词
|
||||
*/
|
||||
getSystemPrompt(availableTools) {
|
||||
return `你是一个专业的金融投资助手。你可以使用以下工具来帮助用户查询信息:
|
||||
|
||||
${availableTools.map(tool => `
|
||||
**${tool.name}**
|
||||
描述:${tool.description}
|
||||
参数:${JSON.stringify(tool.parameters, null, 2)}
|
||||
`).join('\n')}
|
||||
|
||||
用户提问时,请按照以下步骤:
|
||||
1. 理解用户的意图
|
||||
2. 选择合适的工具(可以多个)
|
||||
3. 提取工具需要的参数
|
||||
4. 调用工具后,用自然语言总结结果
|
||||
|
||||
回复格式:
|
||||
- 如果需要调用工具,返回JSON格式:{"tool": "工具名", "arguments": {...}}
|
||||
- 如果不需要工具,直接回复自然语言
|
||||
|
||||
注意:
|
||||
- 贵州茅台的股票代码是 600519
|
||||
- 涨停是指股票当日涨幅达到10%
|
||||
- 概念板块是指相同题材的股票分类`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能对话 - 使用LLM理解意图并调用工具
|
||||
*/
|
||||
async chat(userMessage, conversationHistory = []) {
|
||||
try {
|
||||
// 1. 获取可用工具列表
|
||||
const toolsResult = await mcpService.listTools();
|
||||
if (!toolsResult.success) {
|
||||
throw new Error('获取工具列表失败');
|
||||
}
|
||||
|
||||
const availableTools = toolsResult.data;
|
||||
|
||||
// 2. 构建对话历史
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: this.getSystemPrompt(availableTools),
|
||||
},
|
||||
...conversationHistory.map(msg => ({
|
||||
role: msg.isUser ? 'user' : 'assistant',
|
||||
content: msg.content,
|
||||
})),
|
||||
{
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
},
|
||||
];
|
||||
|
||||
// 3. 调用LLM
|
||||
logger.info('LLMService', '调用LLM', { messageCount: messages.length });
|
||||
|
||||
// 注意:这里需要配置API密钥
|
||||
if (!LLM_CONFIG.apiKey) {
|
||||
// 如果没有配置LLM,使用简单的关键词匹配
|
||||
logger.warn('LLMService', '未配置LLM API密钥,使用简单匹配');
|
||||
return await this.fallbackChat(userMessage);
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
LLM_CONFIG.apiUrl,
|
||||
{
|
||||
model: LLM_CONFIG.model,
|
||||
messages: messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
const aiResponse = response.data.choices[0].message.content;
|
||||
logger.info('LLMService', 'LLM响应', { response: aiResponse });
|
||||
|
||||
// 4. 解析LLM响应
|
||||
// 如果LLM返回工具调用指令
|
||||
try {
|
||||
const toolCall = JSON.parse(aiResponse);
|
||||
if (toolCall.tool && toolCall.arguments) {
|
||||
// 调用MCP工具
|
||||
const toolResult = await mcpService.callTool(toolCall.tool, toolCall.arguments);
|
||||
|
||||
if (!toolResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: toolResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 让LLM总结工具结果
|
||||
const summaryMessages = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: aiResponse,
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
content: `工具 ${toolCall.tool} 返回的数据:\n${JSON.stringify(toolResult.data, null, 2)}\n\n请用自然语言总结这些数据,给用户一个简洁清晰的回复。`,
|
||||
},
|
||||
];
|
||||
|
||||
const summaryResponse = await axios.post(
|
||||
LLM_CONFIG.apiUrl,
|
||||
{
|
||||
model: LLM_CONFIG.model,
|
||||
messages: summaryMessages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 500,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
const summary = summaryResponse.data.choices[0].message.content;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: summary,
|
||||
rawData: toolResult.data,
|
||||
toolUsed: toolCall.tool,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
// 不是JSON格式,说明是直接回复
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: aiResponse,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 默认返回LLM的直接回复
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: aiResponse,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('LLMService', 'chat error', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '对话处理失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 降级方案:简单的关键词匹配(当没有配置LLM时)
|
||||
*/
|
||||
async fallbackChat(userMessage) {
|
||||
logger.info('LLMService', '使用降级方案', { message: userMessage });
|
||||
|
||||
// 使用原有的简单匹配逻辑
|
||||
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
|
||||
const result = await mcpService.callTool('search_china_news', {
|
||||
query: userMessage.replace(/新闻|资讯/g, '').trim(),
|
||||
top_k: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '新闻搜索');
|
||||
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
|
||||
const query = userMessage.replace(/概念|板块/g, '').trim();
|
||||
const result = await mcpService.callTool('search_concepts', {
|
||||
query,
|
||||
size: 5,
|
||||
sort_by: 'change_pct',
|
||||
});
|
||||
return this.formatFallbackResponse(result, '概念搜索');
|
||||
} else if (userMessage.includes('涨停')) {
|
||||
const query = userMessage.replace(/涨停/g, '').trim();
|
||||
const result = await mcpService.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode: 'hybrid',
|
||||
page_size: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '涨停分析');
|
||||
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
|
||||
// 6位数字 = 股票代码
|
||||
const result = await mcpService.callTool('get_stock_basic_info', {
|
||||
seccode: userMessage.trim(),
|
||||
});
|
||||
return this.formatFallbackResponse(result, '股票信息');
|
||||
} else if (userMessage.includes('茅台') || userMessage.includes('贵州茅台')) {
|
||||
// 特殊处理茅台
|
||||
const result = await mcpService.callTool('get_stock_basic_info', {
|
||||
seccode: '600519',
|
||||
});
|
||||
return this.formatFallbackResponse(result, '贵州茅台股票信息');
|
||||
} else {
|
||||
// 默认:搜索新闻
|
||||
const result = await mcpService.callTool('search_china_news', {
|
||||
query: userMessage,
|
||||
top_k: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '新闻搜索');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化降级响应
|
||||
*/
|
||||
formatFallbackResponse(result, action) {
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: `已为您完成${action},找到以下结果:`,
|
||||
rawData: result.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除对话历史
|
||||
*/
|
||||
clearHistory() {
|
||||
this.conversationHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const llmService = new LLMService();
|
||||
|
||||
export default LLMService;
|
||||
248
src/services/mcpService.js
Normal file
248
src/services/mcpService.js
Normal file
@@ -0,0 +1,248 @@
|
||||
// src/services/mcpService.js
|
||||
// MCP (Model Context Protocol) 服务层
|
||||
// 用于与FastAPI后端的MCP工具进行交互
|
||||
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* MCP API客户端
|
||||
*/
|
||||
class MCPService {
|
||||
constructor() {
|
||||
this.baseURL = `${getApiBase()}/mcp`;
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 60000, // 60秒超时(MCP工具可能需要较长时间)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
logger.debug('MCPService', 'Request', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
data: config.data,
|
||||
});
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('MCPService', 'Request Error', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
this.client.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.debug('MCPService', 'Response', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('MCPService', 'Response Error', {
|
||||
url: error.config?.url,
|
||||
status: error.response?.status,
|
||||
message: error.message,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有可用的MCP工具
|
||||
* @returns {Promise<Object>} 工具列表
|
||||
*/
|
||||
async listTools() {
|
||||
try {
|
||||
const response = await this.client.get('/tools');
|
||||
return {
|
||||
success: true,
|
||||
data: response.tools || [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '获取工具列表失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定工具的定义
|
||||
* @param {string} toolName - 工具名称
|
||||
* @returns {Promise<Object>} 工具定义
|
||||
*/
|
||||
async getTool(toolName) {
|
||||
try {
|
||||
const response = await this.client.get(`/tools/${toolName}`);
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '获取工具定义失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用MCP工具
|
||||
* @param {string} toolName - 工具名称
|
||||
* @param {Object} arguments - 工具参数
|
||||
* @returns {Promise<Object>} 工具执行结果
|
||||
*/
|
||||
async callTool(toolName, toolArguments) {
|
||||
try {
|
||||
const response = await this.client.post('/tools/call', {
|
||||
tool: toolName,
|
||||
arguments: toolArguments,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || '工具调用失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能对话 - 根据用户输入自动选择合适的工具
|
||||
* @param {string} userMessage - 用户消息
|
||||
* @param {Array} conversationHistory - 对话历史(可选)
|
||||
* @returns {Promise<Object>} AI响应
|
||||
*/
|
||||
async chat(userMessage, conversationHistory = []) {
|
||||
try {
|
||||
// 这里可以实现智能路由逻辑
|
||||
// 根据用户输入判断应该调用哪个工具
|
||||
|
||||
// 示例:关键词匹配
|
||||
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
|
||||
return await this.callTool('search_china_news', {
|
||||
query: userMessage.replace(/新闻|资讯/g, '').trim(),
|
||||
top_k: 5,
|
||||
});
|
||||
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
|
||||
const query = userMessage.replace(/概念|板块/g, '').trim();
|
||||
return await this.callTool('search_concepts', {
|
||||
query,
|
||||
size: 5,
|
||||
sort_by: 'change_pct',
|
||||
});
|
||||
} else if (userMessage.includes('涨停')) {
|
||||
const query = userMessage.replace(/涨停/g, '').trim();
|
||||
return await this.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode: 'hybrid',
|
||||
page_size: 5,
|
||||
});
|
||||
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
|
||||
// 6位数字 = 股票代码
|
||||
return await this.callTool('get_stock_basic_info', {
|
||||
seccode: userMessage.trim(),
|
||||
});
|
||||
} else {
|
||||
// 默认:搜索新闻
|
||||
return await this.callTool('search_china_news', {
|
||||
query: userMessage,
|
||||
top_k: 5,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '对话处理失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具类别枚举
|
||||
*/
|
||||
static TOOL_CATEGORIES = {
|
||||
NEWS: 'news', // 新闻搜索
|
||||
STOCK: 'stock', // 股票信息
|
||||
CONCEPT: 'concept', // 概念板块
|
||||
LIMIT_UP: 'limit_up', // 涨停分析
|
||||
RESEARCH: 'research', // 研报搜索
|
||||
ROADSHOW: 'roadshow', // 路演信息
|
||||
FINANCIAL: 'financial', // 财务数据
|
||||
TRADE: 'trade', // 交易数据
|
||||
};
|
||||
|
||||
/**
|
||||
* 常用工具快捷方式
|
||||
*/
|
||||
async searchNews(query, topK = 5, exactMatch = false) {
|
||||
return await this.callTool('search_china_news', {
|
||||
query,
|
||||
top_k: topK,
|
||||
exact_match: exactMatch,
|
||||
});
|
||||
}
|
||||
|
||||
async searchConcepts(query, size = 10, sortBy = 'change_pct') {
|
||||
return await this.callTool('search_concepts', {
|
||||
query,
|
||||
size,
|
||||
sort_by: sortBy,
|
||||
});
|
||||
}
|
||||
|
||||
async searchLimitUpStocks(query, mode = 'hybrid', pageSize = 10) {
|
||||
return await this.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode,
|
||||
page_size: pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async getStockInfo(seccode) {
|
||||
return await this.callTool('get_stock_basic_info', {
|
||||
seccode,
|
||||
});
|
||||
}
|
||||
|
||||
async getStockConcepts(stockCode, size = 10) {
|
||||
return await this.callTool('get_stock_concepts', {
|
||||
stock_code: stockCode,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
async searchResearchReports(query, mode = 'hybrid', size = 5) {
|
||||
return await this.callTool('search_research_reports', {
|
||||
query,
|
||||
mode,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
async getConceptStatistics(days = 7, minStockCount = 3) {
|
||||
return await this.callTool('get_concept_statistics', {
|
||||
days,
|
||||
min_stock_count: minStockCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const mcpService = new MCPService();
|
||||
|
||||
// 导出类(供测试使用)
|
||||
export default MCPService;
|
||||
@@ -1,850 +0,0 @@
|
||||
// src/services/mockSocketService.js
|
||||
/**
|
||||
* Mock Socket 服务 - 用于开发环境模拟实时推送
|
||||
* 模拟金融资讯、事件动向、分析报告等实时消息推送
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../constants/notificationTypes';
|
||||
|
||||
// 模拟金融资讯数据
|
||||
const mockFinancialNews = [
|
||||
// ========== 公告通知 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '贵州茅台发布2024年度财报公告',
|
||||
content: '2024年度营收同比增长15.2%,净利润创历史新高,董事会建议每10股派息180元',
|
||||
publishTime: new Date('2024-03-28T15:30:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/ann001',
|
||||
extra: {
|
||||
announcementType: '财报',
|
||||
companyCode: '600519',
|
||||
companyName: '贵州茅台',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.URGENT,
|
||||
title: '宁德时代发布重大资产重组公告',
|
||||
content: '公司拟收购某新能源材料公司100%股权,交易金额约120亿元,预计增厚业绩20%',
|
||||
publishTime: new Date('2024-03-28T09:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/ann002',
|
||||
extra: {
|
||||
announcementType: '重组',
|
||||
companyCode: '300750',
|
||||
companyName: '宁德时代',
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '中国平安发布分红派息公告',
|
||||
content: '2023年度利润分配方案:每10股派发现金红利23.0元(含税),分红率达30.5%',
|
||||
publishTime: new Date('2024-03-27T16:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/ann003',
|
||||
extra: {
|
||||
announcementType: '分红',
|
||||
companyCode: '601318',
|
||||
companyName: '中国平安',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
|
||||
// ========== 股票动向 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.URGENT,
|
||||
title: '您关注的股票触发预警',
|
||||
content: '宁德时代(300750) 当前价格 ¥245.50,盘中涨幅达 +5.2%,已触达您设置的目标价位',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/stock-overview?code=300750',
|
||||
extra: {
|
||||
stockCode: '300750',
|
||||
stockName: '宁德时代',
|
||||
priceChange: '+5.2%',
|
||||
currentPrice: '245.50',
|
||||
triggerType: '目标价',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '您关注的股票异常波动',
|
||||
content: '比亚迪(002594) 5分钟内跌幅达 -3.8%,当前价格 ¥198.20,建议关注',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/stock-overview?code=002594',
|
||||
extra: {
|
||||
stockCode: '002594',
|
||||
stockName: '比亚迪',
|
||||
priceChange: '-3.8%',
|
||||
currentPrice: '198.20',
|
||||
triggerType: '异常波动',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '持仓股票表现',
|
||||
content: '隆基绿能(601012) 今日表现优异,涨幅 +4.5%,您当前持仓浮盈 +¥8,200',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/trading-simulation',
|
||||
extra: {
|
||||
stockCode: '601012',
|
||||
stockName: '隆基绿能',
|
||||
priceChange: '+4.5%',
|
||||
profit: '+8200',
|
||||
},
|
||||
autoClose: 8000,
|
||||
},
|
||||
|
||||
// ========== 事件动向 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '央行宣布降准0.5个百分点',
|
||||
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点,释放长期资金约1万亿元,利好股市',
|
||||
publishTime: new Date('2024-03-28T09:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/evt001',
|
||||
extra: {
|
||||
eventId: 'evt001',
|
||||
relatedStocks: 12,
|
||||
impactLevel: '重大利好',
|
||||
sectors: ['银行', '地产', '基建'],
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '新能源汽车补贴政策延期',
|
||||
content: '财政部宣布新能源汽车购置补贴政策延长至2024年底,涉及比亚迪、理想汽车等5家龙头企业',
|
||||
publishTime: new Date('2024-03-28T10:30:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/evt002',
|
||||
extra: {
|
||||
eventId: 'evt002',
|
||||
relatedStocks: 5,
|
||||
impactLevel: '重大利好',
|
||||
sectors: ['新能源汽车'],
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '芯片产业扶持政策出台',
|
||||
content: '工信部发布《半导体产业发展指导意见》,未来三年投入500亿专项资金支持芯片研发',
|
||||
publishTime: new Date('2024-03-27T14:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/evt003',
|
||||
extra: {
|
||||
eventId: 'evt003',
|
||||
relatedStocks: 8,
|
||||
impactLevel: '中长期利好',
|
||||
sectors: ['半导体', '芯片设计'],
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
|
||||
// ========== 预测通知 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【预测】央行可能宣布降准政策',
|
||||
content: '基于最新宏观数据分析,预计央行将在本周宣布降准0.5个百分点,释放长期资金',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: true,
|
||||
clickable: false, // ❌ 不可点击
|
||||
link: null,
|
||||
extra: {
|
||||
isPrediction: true,
|
||||
statusHint: '详细报告生成中...',
|
||||
relatedPredictionId: 'pred_001',
|
||||
},
|
||||
autoClose: 15000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【预测】新能源补贴政策或将延期',
|
||||
content: '根据政策趋势分析,财政部可能宣布新能源汽车购置补贴政策延长至2025年底',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: true,
|
||||
clickable: false, // ❌ 不可点击
|
||||
link: null,
|
||||
extra: {
|
||||
isPrediction: true,
|
||||
statusHint: '详细报告生成中...',
|
||||
relatedPredictionId: 'pred_002',
|
||||
},
|
||||
autoClose: 15000,
|
||||
},
|
||||
|
||||
// ========== 分析报告 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '医药行业深度报告:创新药迎来政策拐点',
|
||||
content: 'CXO板块持续受益于全球创新药研发外包需求,建议关注药明康德、凯莱英等龙头企业',
|
||||
publishTime: new Date('2024-03-28T08:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: '李明',
|
||||
organization: '中信证券',
|
||||
},
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=rpt001',
|
||||
extra: {
|
||||
reportType: '行业研报',
|
||||
industry: '医药',
|
||||
rating: '强烈推荐',
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: 'AI产业链投资机会分析',
|
||||
content: '随着大模型应用加速落地,算力、数据、应用三大方向均存在投资机会,重点关注海光信息、寒武纪',
|
||||
publishTime: new Date('2024-03-28T07:30:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: '王芳',
|
||||
organization: '招商证券',
|
||||
},
|
||||
isAIGenerated: true,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=rpt002',
|
||||
extra: {
|
||||
reportType: '策略报告',
|
||||
industry: '人工智能',
|
||||
rating: '推荐',
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '比亚迪:新能源汽车龙头业绩持续超预期',
|
||||
content: '2024年销量目标400万辆,海外市场拓展顺利,维持"买入"评级,目标价280元',
|
||||
publishTime: new Date('2024-03-27T09:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: '张伟',
|
||||
organization: '国泰君安',
|
||||
},
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=rpt003',
|
||||
extra: {
|
||||
reportType: '公司研报',
|
||||
industry: '新能源汽车',
|
||||
rating: '买入',
|
||||
targetPrice: '280',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '2024年A股市场展望:结构性行情延续',
|
||||
content: 'AI应用、高端制造、自主可控三大主线贯穿全年,建议关注科技成长板块配置机会',
|
||||
publishTime: new Date('2024-03-26T16:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: 'AI分析师',
|
||||
organization: '价值前沿',
|
||||
},
|
||||
isAIGenerated: true,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=rpt004',
|
||||
extra: {
|
||||
reportType: '策略报告',
|
||||
industry: '市场策略',
|
||||
rating: '谨慎乐观',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
];
|
||||
|
||||
class MockSocketService {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.connecting = false; // 新增:正在连接标志,防止重复连接
|
||||
this.listeners = new Map();
|
||||
this.intervals = [];
|
||||
this.messageQueue = [];
|
||||
this.reconnectAttempts = 0;
|
||||
this.customReconnectTimer = null;
|
||||
this.failConnection = false; // 是否模拟连接失败
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指数退避延迟(Mock 模式使用更短的时间便于测试)
|
||||
* 第1次: 10秒, 第2次: 20秒, 第3次: 40秒, 第4次及以后: 40秒
|
||||
*/
|
||||
getReconnectionDelay(attempt) {
|
||||
const delays = [10000, 20000, 40000]; // 10s, 20s, 40s (缩短10倍便于测试)
|
||||
const index = Math.min(attempt - 1, delays.length - 1);
|
||||
return delays[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到 mock socket
|
||||
*/
|
||||
connect() {
|
||||
// ✅ 防止重复连接
|
||||
if (this.connected) {
|
||||
logger.warn('mockSocketService', 'Already connected');
|
||||
console.log('%c[Mock Socket] Already connected, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connecting) {
|
||||
logger.warn('mockSocketService', 'Connection in progress');
|
||||
console.log('%c[Mock Socket] Connection already in progress, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
this.connecting = true; // 标记为连接中
|
||||
logger.info('mockSocketService', 'Connecting to mock socket service...');
|
||||
console.log('%c[Mock Socket] 🔌 Connecting...', 'color: #2196F3; font-weight: bold;');
|
||||
|
||||
// 模拟连接延迟
|
||||
setTimeout(() => {
|
||||
// 检查是否应该模拟连接失败
|
||||
if (this.failConnection) {
|
||||
this.connecting = false; // 清除连接中标志
|
||||
logger.warn('mockSocketService', 'Simulated connection failure');
|
||||
console.log('%c[Mock Socket] ❌ Connection failed (simulated)', 'color: #F44336; font-weight: bold;');
|
||||
|
||||
// 触发连接错误事件
|
||||
this.emit('connect_error', {
|
||||
message: 'Mock connection error for testing',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// 安排下次重连(会继续失败,直到 failConnection 被清除)
|
||||
this.scheduleReconnection();
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常连接成功
|
||||
this.connected = true;
|
||||
this.connecting = false; // 清除连接中标志
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 清除自定义重连定时器
|
||||
if (this.customReconnectTimer) {
|
||||
clearTimeout(this.customReconnectTimer);
|
||||
this.customReconnectTimer = null;
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Mock socket connected successfully');
|
||||
console.log('%c[Mock Socket] ✅ Connected successfully!', 'color: #4CAF50; font-weight: bold; font-size: 14px;');
|
||||
console.log(`%c[Mock Socket] Status: connected=${this.connected}, connecting=${this.connecting}`, 'color: #4CAF50;');
|
||||
|
||||
// ✅ 使用 setTimeout(0) 确保监听器已注册后再触发事件
|
||||
setTimeout(() => {
|
||||
console.log('%c[Mock Socket] Emitting connect event...', 'color: #9C27B0;');
|
||||
this.emit('connect', { timestamp: Date.now() });
|
||||
console.log('%c[Mock Socket] Connect event emitted', 'color: #9C27B0;');
|
||||
}, 0);
|
||||
|
||||
// 在连接后3秒发送欢迎消息
|
||||
setTimeout(() => {
|
||||
if (this.connected) {
|
||||
this.emit('new_event', {
|
||||
type: 'system_notification',
|
||||
severity: 'info',
|
||||
title: '连接成功',
|
||||
message: '实时消息推送服务已启动 (Mock 模式)',
|
||||
timestamp: Date.now(),
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
* @param {boolean} triggerReconnect - 是否触发自动重连(模拟意外断开)
|
||||
*/
|
||||
disconnect(triggerReconnect = false) {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Disconnecting from mock socket service...');
|
||||
|
||||
// 清除所有定时器
|
||||
this.intervals.forEach(interval => clearInterval(interval));
|
||||
this.intervals = [];
|
||||
|
||||
const wasConnected = this.connected;
|
||||
this.connected = false;
|
||||
this.emit('disconnect', {
|
||||
timestamp: Date.now(),
|
||||
reason: triggerReconnect ? 'transport close' : 'io client disconnect'
|
||||
});
|
||||
|
||||
// 如果需要触发重连(模拟意外断开)
|
||||
if (triggerReconnect && wasConnected) {
|
||||
this.scheduleReconnection();
|
||||
} else {
|
||||
// 清除重连定时器
|
||||
if (this.customReconnectTimer) {
|
||||
clearTimeout(this.customReconnectTimer);
|
||||
this.customReconnectTimer = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指数退避策略安排重连
|
||||
*/
|
||||
scheduleReconnection() {
|
||||
// 清除之前的定时器
|
||||
if (this.customReconnectTimer) {
|
||||
clearTimeout(this.customReconnectTimer);
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.getReconnectionDelay(this.reconnectAttempts);
|
||||
logger.info('mockSocketService', `Scheduling reconnection in ${delay / 1000}s (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
// 触发 connect_error 事件通知UI
|
||||
this.emit('connect_error', {
|
||||
message: 'Mock connection error for testing',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
this.customReconnectTimer = setTimeout(() => {
|
||||
if (!this.connected) {
|
||||
logger.info('mockSocketService', 'Attempting reconnection...', {
|
||||
attempt: this.reconnectAttempts,
|
||||
});
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重连
|
||||
* @returns {boolean} 是否触发重连
|
||||
*/
|
||||
reconnect() {
|
||||
if (this.connected) {
|
||||
logger.info('mockSocketService', 'Already connected, no need to reconnect');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Manually triggering reconnection...');
|
||||
|
||||
// 清除自动重连定时器
|
||||
if (this.customReconnectTimer) {
|
||||
clearTimeout(this.customReconnectTimer);
|
||||
this.customReconnectTimer = null;
|
||||
}
|
||||
|
||||
// 重置重连计数
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 立即触发重连
|
||||
this.connect();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟意外断线(测试用)
|
||||
* @param {number} duration - 断线持续时间(毫秒),0表示需要手动重连
|
||||
*/
|
||||
simulateDisconnection(duration = 0) {
|
||||
logger.info('mockSocketService', `Simulating disconnection${duration > 0 ? ` for ${duration}ms` : ' (manual reconnect required)'}...`);
|
||||
|
||||
if (duration > 0) {
|
||||
// 短暂断线,自动重连
|
||||
this.disconnect(true);
|
||||
} else {
|
||||
// 需要手动重连
|
||||
this.disconnect(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟持续连接失败(测试用)
|
||||
* 连接会一直失败,直到调用 allowReconnection()
|
||||
*/
|
||||
simulateConnectionFailure() {
|
||||
logger.info('mockSocketService', '🚫 Simulating persistent connection failure...');
|
||||
logger.info('mockSocketService', 'Connection will keep failing until allowReconnection() is called');
|
||||
|
||||
// 设置失败标志
|
||||
this.failConnection = true;
|
||||
|
||||
// 如果当前已连接,先断开并触发重连(会失败)
|
||||
if (this.connected) {
|
||||
this.disconnect(true);
|
||||
} else {
|
||||
// 如果未连接,直接触发一次连接尝试(会失败)
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许重连成功(测试用)
|
||||
* 清除连接失败标志,下次重连将会成功
|
||||
*/
|
||||
allowReconnection() {
|
||||
logger.info('mockSocketService', '✅ Allowing reconnection to succeed...');
|
||||
logger.info('mockSocketService', 'Next reconnection attempt will succeed');
|
||||
|
||||
// 清除失败标志
|
||||
this.failConnection = false;
|
||||
|
||||
// 不立即重连,等待自动重连或手动重连
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听事件
|
||||
* @param {string} event - 事件名称
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(callback);
|
||||
|
||||
logger.info('mockSocketService', `Event listener added: ${event}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听
|
||||
* @param {string} event - 事件名称
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbacks = this.listeners.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
|
||||
if (index !== -1) {
|
||||
callbacks.splice(index, 1);
|
||||
logger.info('mockSocketService', `Event listener removed: ${event}`);
|
||||
}
|
||||
|
||||
// 如果没有监听器了,删除该事件
|
||||
if (callbacks.length === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
* @param {string} event - 事件名称
|
||||
* @param {*} data - 事件数据
|
||||
*/
|
||||
emit(event, data) {
|
||||
if (!this.listeners.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbacks = this.listeners.get(event);
|
||||
callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
logger.error('mockSocketService', 'emit', error, { event, data });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动模拟消息推送
|
||||
* @param {number} interval - 推送间隔(毫秒)
|
||||
* @param {number} burstCount - 每次推送的消息数量(1-3条)
|
||||
*/
|
||||
startMockPush(interval = 15000, burstCount = 1) {
|
||||
if (!this.connected) {
|
||||
logger.warn('mockSocketService', 'Cannot start mock push: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', `Starting mock push: interval=${interval}ms, burst=${burstCount}`);
|
||||
|
||||
const pushInterval = setInterval(() => {
|
||||
// 随机选择 1-burstCount 条消息
|
||||
const count = Math.floor(Math.random() * burstCount) + 1;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 从模拟数据中随机选择一条
|
||||
const randomIndex = Math.floor(Math.random() * mockFinancialNews.length);
|
||||
const alert = {
|
||||
...mockFinancialNews[randomIndex],
|
||||
timestamp: Date.now(),
|
||||
id: `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
};
|
||||
|
||||
// 延迟发送(模拟层叠效果)
|
||||
setTimeout(() => {
|
||||
this.emit('new_event', alert);
|
||||
logger.info('mockSocketService', 'Mock notification sent', alert);
|
||||
}, i * 500); // 每条消息间隔500ms
|
||||
}
|
||||
}, interval);
|
||||
|
||||
this.intervals.push(pushInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止模拟推送
|
||||
*/
|
||||
stopMockPush() {
|
||||
this.intervals.forEach(interval => clearInterval(interval));
|
||||
this.intervals = [];
|
||||
logger.info('mockSocketService', 'Mock push stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一条测试消息
|
||||
* @param {object} customData - 自定义消息数据(可选)
|
||||
*/
|
||||
sendTestNotification(customData = null) {
|
||||
const notification = customData || {
|
||||
type: 'trade_alert',
|
||||
severity: 'info',
|
||||
title: '测试消息',
|
||||
message: '这是一条手动触发的测试消息',
|
||||
timestamp: Date.now(),
|
||||
autoClose: 5000,
|
||||
id: `test_${Date.now()}`,
|
||||
};
|
||||
|
||||
this.emit('new_event', notification);
|
||||
logger.info('mockSocketService', 'Test notification sent', notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前重连尝试次数
|
||||
*/
|
||||
getReconnectAttempts() {
|
||||
return this.reconnectAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大重连次数(Mock 模式无限重试)
|
||||
*/
|
||||
getMaxReconnectAttempts() {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件推送(Mock 实现)
|
||||
* @param {object} options - 订阅选项
|
||||
* @param {string} options.eventType - 事件类型 ('all' | 'policy' | 'market' | 'tech' | ...)
|
||||
* @param {string} options.importance - 重要性 ('all' | 'S' | 'A' | 'B' | 'C')
|
||||
* @param {Function} options.onNewEvent - 收到新事件时的回调函数
|
||||
* @param {Function} options.onSubscribed - 订阅成功的回调函数(可选)
|
||||
*/
|
||||
subscribeToEvents(options = {}) {
|
||||
const {
|
||||
eventType = 'all',
|
||||
importance = 'all',
|
||||
onNewEvent,
|
||||
onSubscribed,
|
||||
} = options;
|
||||
|
||||
logger.info('mockSocketService', 'Subscribing to events', { eventType, importance });
|
||||
|
||||
// Mock: 立即触发订阅成功回调
|
||||
if (onSubscribed) {
|
||||
setTimeout(() => {
|
||||
onSubscribed({
|
||||
success: true,
|
||||
event_type: eventType,
|
||||
importance: importance,
|
||||
message: 'Mock subscription confirmed'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Mock: 如果提供了 onNewEvent 回调,监听 'new_event' 事件
|
||||
if (onNewEvent) {
|
||||
// 先移除之前的监听器(避免重复)
|
||||
this.off('new_event', onNewEvent);
|
||||
// 添加新的监听器
|
||||
this.on('new_event', onNewEvent);
|
||||
logger.info('mockSocketService', 'Event listener registered for new_event');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅事件推送(Mock 实现)
|
||||
* @param {object} options - 取消订阅选项
|
||||
* @param {string} options.eventType - 事件类型
|
||||
* @param {Function} options.onUnsubscribed - 取消订阅成功的回调函数(可选)
|
||||
*/
|
||||
unsubscribeFromEvents(options = {}) {
|
||||
const {
|
||||
eventType = 'all',
|
||||
onUnsubscribed,
|
||||
} = options;
|
||||
|
||||
logger.info('mockSocketService', 'Unsubscribing from events', { eventType });
|
||||
|
||||
// Mock: 移除 new_event 监听器
|
||||
this.off('new_event');
|
||||
|
||||
// Mock: 立即触发取消订阅成功回调
|
||||
if (onUnsubscribed) {
|
||||
setTimeout(() => {
|
||||
onUnsubscribed({
|
||||
success: true,
|
||||
event_type: eventType,
|
||||
message: 'Mock unsubscription confirmed'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:订阅所有类型的事件(Mock 实现)
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||
*/
|
||||
subscribeToAllEvents(onNewEvent) {
|
||||
this.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onNewEvent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:订阅指定重要性的事件(Mock 实现)
|
||||
* @param {string} importance - 重要性级别 ('S' | 'A' | 'B' | 'C')
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||
*/
|
||||
subscribeToImportantEvents(importance, onNewEvent) {
|
||||
this.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance,
|
||||
onNewEvent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:订阅指定类型的事件(Mock 实现)
|
||||
* @param {string} eventType - 事件类型
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||
*/
|
||||
subscribeToEventType(eventType, onNewEvent) {
|
||||
this.subscribeToEvents({
|
||||
eventType,
|
||||
importance: 'all',
|
||||
onNewEvent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const mockSocketService = new MockSocketService();
|
||||
|
||||
// 开发模式下添加全局测试函数
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.__mockSocket = {
|
||||
// 模拟意外断线(自动重连成功)
|
||||
simulateDisconnection: () => {
|
||||
logger.info('mockSocketService', '🔌 Simulating disconnection (will auto-reconnect)...');
|
||||
mockSocketService.simulateDisconnection(1); // 触发自动重连
|
||||
},
|
||||
|
||||
// 模拟持续连接失败
|
||||
simulateConnectionFailure: () => {
|
||||
logger.info('mockSocketService', '🚫 Simulating connection failure (will keep retrying)...');
|
||||
mockSocketService.simulateConnectionFailure();
|
||||
},
|
||||
|
||||
// 允许重连成功
|
||||
allowReconnection: () => {
|
||||
logger.info('mockSocketService', '✅ Allowing next reconnection to succeed...');
|
||||
mockSocketService.allowReconnection();
|
||||
},
|
||||
|
||||
// 获取连接状态
|
||||
isConnected: () => {
|
||||
const connected = mockSocketService.isConnected();
|
||||
logger.info('mockSocketService', `Connection status: ${connected ? '✅ Connected' : '❌ Disconnected'}`);
|
||||
return connected;
|
||||
},
|
||||
|
||||
// 手动重连
|
||||
reconnect: () => {
|
||||
logger.info('mockSocketService', '🔄 Manually triggering reconnection...');
|
||||
return mockSocketService.reconnect();
|
||||
},
|
||||
|
||||
// 获取重连尝试次数
|
||||
getAttempts: () => {
|
||||
const attempts = mockSocketService.getReconnectAttempts();
|
||||
logger.info('mockSocketService', `Current reconnection attempts: ${attempts}`);
|
||||
return attempts;
|
||||
},
|
||||
};
|
||||
|
||||
logger.info('mockSocketService', '💡 Mock Socket test functions available:');
|
||||
logger.info('mockSocketService', ' __mockSocket.simulateDisconnection() - 模拟断线(自动重连成功)');
|
||||
logger.info('mockSocketService', ' __mockSocket.simulateConnectionFailure() - 模拟连接失败(持续失败)');
|
||||
logger.info('mockSocketService', ' __mockSocket.allowReconnection() - 允许重连成功');
|
||||
logger.info('mockSocketService', ' __mockSocket.isConnected() - 查看连接状态');
|
||||
logger.info('mockSocketService', ' __mockSocket.reconnect() - 手动重连');
|
||||
logger.info('mockSocketService', ' __mockSocket.getAttempts() - 查看重连次数');
|
||||
}
|
||||
|
||||
export default mockSocketService;
|
||||
@@ -1,97 +1,34 @@
|
||||
// src/services/socket/index.js
|
||||
/**
|
||||
* Socket 服务统一导出
|
||||
* 根据环境变量自动选择使用 Mock 或真实 Socket.IO 服务
|
||||
* 使用真实 Socket.IO 服务连接后端
|
||||
*/
|
||||
|
||||
import { mockSocketService } from '../mockSocketService';
|
||||
import { socketService } from '../socketService';
|
||||
|
||||
// 判断是否使用 Mock
|
||||
const useMock = process.env.REACT_APP_ENABLE_MOCK === 'true' || process.env.REACT_APP_USE_MOCK_SOCKET === 'true';
|
||||
// 导出 socket 服务
|
||||
export const socket = socketService;
|
||||
export { socketService };
|
||||
|
||||
// 根据环境选择服务
|
||||
export const socket = useMock ? mockSocketService : socketService;
|
||||
// ⚡ 新增:暴露 Socket 实例到 window(用于调试和验证)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.socket = socketService;
|
||||
window.socketService = socketService;
|
||||
|
||||
// 同时导出两个服务,方便测试和调试
|
||||
export { mockSocketService, socketService };
|
||||
|
||||
// 导出服务类型标识
|
||||
export const SOCKET_TYPE = useMock ? 'MOCK' : 'REAL';
|
||||
console.log(
|
||||
'%c[Socket Service] ✅ Socket instance exposed to window',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
console.log(' 📍 window.socket:', window.socket);
|
||||
console.log(' 📍 window.socketService:', window.socketService);
|
||||
console.log(' 📍 Socket.IO instance:', window.socket?.socket);
|
||||
console.log(' 📍 Connection status:', window.socket?.connected ? '✅ Connected' : '❌ Disconnected');
|
||||
}
|
||||
|
||||
// 打印当前使用的服务类型
|
||||
console.log(
|
||||
`%c[Socket Service] Using ${SOCKET_TYPE} Socket Service`,
|
||||
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
|
||||
'%c[Socket Service] Using REAL Socket Service',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 12px;'
|
||||
);
|
||||
|
||||
// ========== 暴露调试 API 到全局 ==========
|
||||
if (typeof window !== 'undefined') {
|
||||
// 暴露 Socket 类型到全局
|
||||
window.SOCKET_TYPE = SOCKET_TYPE;
|
||||
|
||||
// 暴露调试 API
|
||||
window.__SOCKET_DEBUG__ = {
|
||||
// 获取当前连接状态
|
||||
getStatus: () => {
|
||||
const isConnected = socket.connected || false;
|
||||
return {
|
||||
type: SOCKET_TYPE,
|
||||
connected: isConnected,
|
||||
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
|
||||
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
|
||||
service: useMock ? 'mockSocketService' : 'socketService',
|
||||
};
|
||||
},
|
||||
|
||||
// 手动重连
|
||||
reconnect: () => {
|
||||
console.log('[Socket Debug] Manual reconnect triggered');
|
||||
if (socket.reconnect) {
|
||||
socket.reconnect();
|
||||
} else {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
}
|
||||
},
|
||||
|
||||
// 断开连接
|
||||
disconnect: () => {
|
||||
console.log('[Socket Debug] Manual disconnect triggered');
|
||||
socket.disconnect();
|
||||
},
|
||||
|
||||
// 连接
|
||||
connect: () => {
|
||||
console.log('[Socket Debug] Manual connect triggered');
|
||||
socket.connect();
|
||||
},
|
||||
|
||||
// 获取服务实例 (仅用于调试)
|
||||
getService: () => socket,
|
||||
|
||||
// 导出诊断信息
|
||||
exportDiagnostics: () => {
|
||||
const status = window.__SOCKET_DEBUG__.getStatus();
|
||||
const diagnostics = {
|
||||
...status,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
};
|
||||
console.log('[Socket Diagnostics]', diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
};
|
||||
|
||||
console.log(
|
||||
'%c[Socket Debug] Debug API available at window.__SOCKET_DEBUG__',
|
||||
'color: #2196F3; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
}
|
||||
|
||||
export default socket;
|
||||
|
||||
@@ -16,6 +16,7 @@ class SocketService {
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = Infinity; // 无限重试
|
||||
this.customReconnectTimer = null; // 自定义重连定时器
|
||||
this.pendingListeners = []; // 暂存等待注册的事件监听器
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +51,23 @@ class SocketService {
|
||||
...options,
|
||||
});
|
||||
|
||||
// 注册所有暂存的事件监听器(保留 pendingListeners,不清空)
|
||||
if (this.pendingListeners.length > 0) {
|
||||
console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
|
||||
this.pendingListeners.forEach(({ event, callback }) => {
|
||||
// 直接在 Socket.IO 实例上注册(避免递归调用 this.on())
|
||||
const wrappedCallback = (...args) => {
|
||||
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
|
||||
console.log(`[socketService] 事件数据 (${event}):`, ...args);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
this.socket.on(event, wrappedCallback);
|
||||
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
|
||||
});
|
||||
// ⚠️ 重要:不清空 pendingListeners,保留用于重连
|
||||
}
|
||||
|
||||
// 监听连接成功
|
||||
this.socket.on('connect', () => {
|
||||
this.connected = true;
|
||||
@@ -64,6 +82,12 @@ class SocketService {
|
||||
logger.info('socketService', 'Socket.IO connected successfully', {
|
||||
socketId: this.socket.id,
|
||||
});
|
||||
|
||||
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] Socket ID:', this.socket.id);
|
||||
|
||||
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
|
||||
// this.subscribeToAllEvents();
|
||||
});
|
||||
|
||||
// 监听断开连接
|
||||
@@ -141,12 +165,31 @@ class SocketService {
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.socket) {
|
||||
logger.warn('socketService', 'Cannot listen to event: socket not initialized', { event });
|
||||
// Socket 未初始化,暂存监听器(检查是否已存在,避免重复)
|
||||
const exists = this.pendingListeners.some(
|
||||
(listener) => listener.event === event && listener.callback === callback
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
logger.info('socketService', 'Socket not ready, queuing listener', { event });
|
||||
console.log(`[socketService] 📦 Socket 未初始化,暂存事件监听器: ${event}`);
|
||||
this.pendingListeners.push({ event, callback });
|
||||
} else {
|
||||
console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.on(event, callback);
|
||||
// 包装回调函数,添加日志
|
||||
const wrappedCallback = (...args) => {
|
||||
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
|
||||
console.log(`[socketService] 事件数据 (${event}):`, ...args);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
this.socket.on(event, wrappedCallback);
|
||||
logger.info('socketService', `Event listener added: ${event}`);
|
||||
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,13 +380,14 @@ class SocketService {
|
||||
});
|
||||
|
||||
// 监听新事件推送
|
||||
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext)
|
||||
// 多个监听器可以共存,都会被触发
|
||||
if (onNewEvent) {
|
||||
console.log('[SocketService DEBUG] 设置 new_event 监听器');
|
||||
// 先移除之前的监听器(避免重复)
|
||||
this.socket.off('new_event');
|
||||
console.log('[SocketService DEBUG] ✓ 已移除旧的 new_event 监听器');
|
||||
|
||||
// 添加新的监听器
|
||||
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
|
||||
|
||||
// 添加新的监听器(与其他监听器共存)
|
||||
this.socket.on('new_event', (eventData) => {
|
||||
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
|
||||
console.log('[SocketService DEBUG] 事件数据:', eventData);
|
||||
@@ -355,7 +399,7 @@ class SocketService {
|
||||
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
|
||||
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
|
||||
});
|
||||
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置');
|
||||
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
|
||||
}
|
||||
|
||||
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
|
||||
@@ -403,14 +447,26 @@ class SocketService {
|
||||
|
||||
/**
|
||||
* 快捷方法:订阅所有类型的事件
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数(可选)
|
||||
* @returns {Function} 取消订阅的函数
|
||||
*/
|
||||
subscribeToAllEvents(onNewEvent) {
|
||||
console.log('%c[socketService] 🔔 自动订阅所有事件...', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
// 如果没有提供回调,添加一个默认的日志回调
|
||||
const defaultCallback = (event) => {
|
||||
console.log('%c[socketService] 📨 收到新事件(默认回调)', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] 事件数据:', event);
|
||||
};
|
||||
|
||||
this.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onNewEvent,
|
||||
onNewEvent: onNewEvent || defaultCallback,
|
||||
onSubscribed: (data) => {
|
||||
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] 订阅确认:', data);
|
||||
},
|
||||
});
|
||||
|
||||
// 返回取消订阅的清理函数
|
||||
|
||||
35
src/styles/bytedesk-override.css
Normal file
35
src/styles/bytedesk-override.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Bytedesk 客服系统 z-index 覆盖样式
|
||||
*
|
||||
* 问题: Bytedesk 默认 z-index 为 10001,项目中部分元素使用 z-index: 99999
|
||||
* 导致客服 iframe 在首页被内容区覆盖,不可见
|
||||
*
|
||||
* 解决方案: 将所有 Bytedesk 相关元素的 z-index 提升到 999999
|
||||
* 确保客服窗口始终显示在最上层
|
||||
*/
|
||||
|
||||
/* Bytedesk 主容器 - 客服图标按钮 */
|
||||
[class*="bytedesk"],
|
||||
[id*="bytedesk"],
|
||||
[class*="BytedeskWeb"] {
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
|
||||
/* Bytedesk iframe - 聊天窗口 */
|
||||
iframe[src*="bytedesk"],
|
||||
iframe[src*="/chat/"],
|
||||
iframe[src*="/visitor/"] {
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
|
||||
/* Bytedesk 覆盖层(如果存在) */
|
||||
.bytedesk-overlay,
|
||||
[class*="bytedesk-overlay"] {
|
||||
z-index: 999998 !important;
|
||||
}
|
||||
|
||||
/* Bytedesk 通知气泡 */
|
||||
.bytedesk-badge,
|
||||
[class*="bytedesk-badge"] {
|
||||
z-index: 1000000 !important;
|
||||
}
|
||||
98
src/utils/colorUtils.js
Normal file
98
src/utils/colorUtils.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// src/utils/colorUtils.js
|
||||
// 颜色工具函数 - 根据涨跌幅动态计算颜色深浅
|
||||
|
||||
/**
|
||||
* 根据涨跌幅获取颜色(深浅动态变化)
|
||||
* @param {number} change - 涨跌幅百分比
|
||||
* @returns {string} Chakra UI 颜色值
|
||||
*/
|
||||
export const getChangeColor = (change) => {
|
||||
if (change === null || change === undefined || isNaN(change)) {
|
||||
return 'gray.500';
|
||||
}
|
||||
|
||||
const absChange = Math.abs(change);
|
||||
|
||||
if (change > 0) {
|
||||
// 涨:红色系,根据涨幅深浅
|
||||
if (absChange >= 9) return 'red.900'; // 涨停或接近涨停
|
||||
if (absChange >= 7) return 'red.800';
|
||||
if (absChange >= 5) return 'red.700';
|
||||
if (absChange >= 3) return 'red.600';
|
||||
if (absChange >= 2) return 'red.500';
|
||||
if (absChange >= 1) return 'red.400';
|
||||
return 'red.300'; // 微涨
|
||||
} else if (change < 0) {
|
||||
// 跌:绿色系,根据跌幅深浅
|
||||
if (absChange >= 9) return 'green.900'; // 跌停或接近跌停
|
||||
if (absChange >= 7) return 'green.800';
|
||||
if (absChange >= 5) return 'green.700';
|
||||
if (absChange >= 3) return 'green.600';
|
||||
if (absChange >= 2) return 'green.500';
|
||||
if (absChange >= 1) return 'green.400';
|
||||
return 'green.300'; // 微跌
|
||||
}
|
||||
|
||||
return 'gray.500'; // 平盘
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取涨跌幅背景渐变色(用于精简模式卡片)
|
||||
* @param {number} change - 涨跌幅百分比
|
||||
* @param {boolean} useDark - 是否使用深色模式
|
||||
* @returns {string} Chakra UI bgGradient 值
|
||||
*/
|
||||
export const getChangeBackgroundGradient = (change, useDark = false) => {
|
||||
if (change === null || change === undefined || isNaN(change)) {
|
||||
return 'linear(to-br, gray.50, gray.100)';
|
||||
}
|
||||
|
||||
const absChange = Math.abs(change);
|
||||
|
||||
if (change > 0) {
|
||||
// 涨:红色渐变背景
|
||||
if (absChange >= 9) return 'linear(to-br, red.100, red.200)';
|
||||
if (absChange >= 7) return 'linear(to-br, red.50, red.150)';
|
||||
if (absChange >= 5) return 'linear(to-br, red.50, red.100)';
|
||||
if (absChange >= 3) return 'linear(to-br, red.50, red.100)';
|
||||
return 'linear(to-br, red.50, red.50)';
|
||||
} else if (change < 0) {
|
||||
// 跌:绿色渐变背景
|
||||
if (absChange >= 9) return 'linear(to-br, green.100, green.200)';
|
||||
if (absChange >= 7) return 'linear(to-br, green.50, green.150)';
|
||||
if (absChange >= 5) return 'linear(to-br, green.50, green.100)';
|
||||
if (absChange >= 3) return 'linear(to-br, green.50, green.100)';
|
||||
return 'linear(to-br, green.50, green.50)';
|
||||
}
|
||||
|
||||
return 'linear(to-br, gray.50, gray.100)';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取涨跌幅边框颜色
|
||||
* @param {number} change - 涨跌幅百分比
|
||||
* @returns {string} Chakra UI 颜色值
|
||||
*/
|
||||
export const getChangeBorderColor = (change) => {
|
||||
if (change === null || change === undefined || isNaN(change)) {
|
||||
return 'gray.300';
|
||||
}
|
||||
|
||||
const absChange = Math.abs(change);
|
||||
|
||||
if (change > 0) {
|
||||
if (absChange >= 9) return 'red.700';
|
||||
if (absChange >= 7) return 'red.600';
|
||||
if (absChange >= 5) return 'red.500';
|
||||
if (absChange >= 3) return 'red.400';
|
||||
return 'red.300';
|
||||
} else if (change < 0) {
|
||||
if (absChange >= 9) return 'green.700';
|
||||
if (absChange >= 7) return 'green.600';
|
||||
if (absChange >= 5) return 'green.500';
|
||||
if (absChange >= 3) return 'green.400';
|
||||
return 'green.300';
|
||||
}
|
||||
|
||||
return 'gray.300';
|
||||
};
|
||||
1038
src/views/AgentChat/index.js
Normal file
1038
src/views/AgentChat/index.js
Normal file
File diff suppressed because it is too large
Load Diff
53
src/views/AgentChat/index_backup.js
Normal file
53
src/views/AgentChat/index_backup.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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;
|
||||
857
src/views/AgentChat/index_v3.js
Normal file
857
src/views/AgentChat/index_v3.js
Normal file
@@ -0,0 +1,857 @@
|
||||
// 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;
|
||||
@@ -25,8 +25,12 @@ import {
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
useDisclosure,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
import { TimeIcon, BellIcon } from '@chakra-ui/icons';
|
||||
import { useNotification } from '../../../contexts/NotificationContext';
|
||||
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
|
||||
import PaginationControl from './DynamicNewsCard/PaginationControl';
|
||||
@@ -54,6 +58,7 @@ let dynamicNewsCardRenderCount = 0;
|
||||
* @param {Function} onSearchFocus - 搜索框获得焦点回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Object} trackingFunctions - PostHog 追踪函数集合
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const DynamicNewsCard = forwardRef(({
|
||||
@@ -64,6 +69,7 @@ const DynamicNewsCard = forwardRef(({
|
||||
onSearchFocus,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
trackingFunctions = {},
|
||||
...rest
|
||||
}, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -71,6 +77,9 @@ const DynamicNewsCard = forwardRef(({
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// 通知权限相关
|
||||
const { browserPermission, requestBrowserPermission } = useNotification();
|
||||
|
||||
// 固定模式状态
|
||||
const [isFixedMode, setIsFixedMode] = useState(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
@@ -80,7 +89,7 @@ const DynamicNewsCard = forwardRef(({
|
||||
// 导航栏和页脚固定高度
|
||||
const NAVBAR_HEIGHT = 64; // 主导航高度
|
||||
const SECONDARY_NAV_HEIGHT = 44; // 二级导航高度
|
||||
const FOOTER_HEIGHT = 120; // 页脚高度(预留)
|
||||
const FOOTER_HEIGHT = 80; // 页脚高度(优化后)
|
||||
const TOTAL_NAV_HEIGHT = NAVBAR_HEIGHT + SECONDARY_NAV_HEIGHT; // 总导航高度 128px
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
@@ -144,6 +153,23 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
dispatch(toggleEventFollow(eventId));
|
||||
}, [dispatch]);
|
||||
|
||||
// 通知开关处理
|
||||
const handleNotificationToggle = useCallback(async () => {
|
||||
if (browserPermission === 'granted') {
|
||||
// 已授权,提示用户去浏览器设置中关闭
|
||||
toast({
|
||||
title: '已开启通知',
|
||||
description: '要关闭通知,请在浏览器地址栏左侧点击锁图标,找到"通知"选项进行设置',
|
||||
status: 'info',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
// 未授权,请求权限
|
||||
await requestBrowserPermission();
|
||||
}
|
||||
}, [browserPermission, requestBrowserPermission, toast]);
|
||||
|
||||
// 本地状态
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
@@ -205,9 +231,22 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
// 四排模式的事件点击处理(打开弹窗)
|
||||
const handleFourRowEventClick = useCallback((event) => {
|
||||
console.log('%c🔲 [四排模式] 点击事件,打开详情弹窗', 'color: #8B5CF6; font-weight: bold;', { eventId: event.id, title: event.title });
|
||||
|
||||
// 🎯 追踪事件详情打开
|
||||
if (trackingFunctions.trackNewsDetailOpened) {
|
||||
trackingFunctions.trackNewsDetailOpened({
|
||||
eventId: event.id,
|
||||
eventTitle: event.title,
|
||||
importance: event.importance,
|
||||
source: 'four_row_mode',
|
||||
displayMode: 'modal',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
setModalEvent(event);
|
||||
onModalOpen();
|
||||
}, [onModalOpen]);
|
||||
}, [onModalOpen, trackingFunctions]);
|
||||
|
||||
// 初始加载 - 只在组件首次挂载且对应模式数据为空时执行
|
||||
useEffect(() => {
|
||||
@@ -302,6 +341,18 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
console.log('%c🎯 [首次加载] 自动选中第一个事件', 'color: #10B981; font-weight: bold;');
|
||||
hasAutoSelectedFirstEvent.current = true;
|
||||
setSelectedEvent(currentPageEvents[0]);
|
||||
|
||||
// 🎯 追踪事件点击(首次自动选中)
|
||||
if (trackingFunctions.trackNewsArticleClicked) {
|
||||
trackingFunctions.trackNewsArticleClicked({
|
||||
eventId: currentPageEvents[0].id,
|
||||
eventTitle: currentPageEvents[0].title,
|
||||
importance: currentPageEvents[0].importance,
|
||||
source: 'auto_select_first',
|
||||
displayMode: mode,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -310,7 +361,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
e => e.id === selectedEvent?.id
|
||||
);
|
||||
}
|
||||
}, [currentPageEvents, selectedEvent?.id, mode]);
|
||||
}, [currentPageEvents, selectedEvent?.id, mode, trackingFunctions]);
|
||||
|
||||
// 组件卸载时清理选中状态
|
||||
useEffect(() => {
|
||||
@@ -364,7 +415,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
if (!cardHeaderElement || !cardBodyElement) return;
|
||||
|
||||
let ticking = false;
|
||||
const TRIGGER_OFFSET = 100; // 提前 100px 触发
|
||||
const TRIGGER_OFFSET = 150; // 提前 150px 触发(进入固定模式)
|
||||
const EXIT_OFFSET = 200; // 提前 200px 退出(退出比进入更容易)
|
||||
const EXIT_THRESHOLD = 30; // 接近顶部 30px 内即可退出
|
||||
|
||||
// 外部滚动监听:触发固定模式
|
||||
const handleExternalScroll = () => {
|
||||
@@ -375,7 +428,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
const rect = cardHeaderElement.getBoundingClientRect();
|
||||
const elementTop = rect.top;
|
||||
|
||||
// 计算触发点:总导航高度 + 100px 偏移量
|
||||
// 计算触发点:总导航高度 + 150px 偏移量
|
||||
const triggerPoint = TOTAL_NAV_HEIGHT + TRIGGER_OFFSET;
|
||||
|
||||
// 向上滑动:元素顶部到达触发点 → 激活固定模式
|
||||
@@ -402,25 +455,31 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
|
||||
// 检测向上滚动(deltaY < 0)
|
||||
if (e.deltaY < 0) {
|
||||
// 查找所有滚动容器
|
||||
const scrollContainers = cardBodyElement.querySelectorAll('[data-scroll-container]');
|
||||
window.requestAnimationFrame(() => {
|
||||
// 🎯 检查 1:CardHeader 位置(主要条件)
|
||||
const rect = cardHeaderElement.getBoundingClientRect();
|
||||
const elementTop = rect.top;
|
||||
const exitPoint = TOTAL_NAV_HEIGHT + EXIT_OFFSET;
|
||||
|
||||
if (scrollContainers.length === 0) {
|
||||
// 如果没有找到标记的容器,查找所有可滚动元素
|
||||
const allScrollable = cardBodyElement.querySelectorAll('[style*="overflow"]');
|
||||
scrollContainers = allScrollable;
|
||||
}
|
||||
// 🎯 检查 2:左侧事件列表滚动位置(辅助条件)
|
||||
const eventListContainers = cardBodyElement.querySelectorAll('[data-event-list-container]');
|
||||
const allNearTop = eventListContainers.length === 0 ||
|
||||
Array.from(eventListContainers).every(
|
||||
container => container.scrollTop <= EXIT_THRESHOLD
|
||||
);
|
||||
|
||||
// 检查是否所有滚动容器都在顶部
|
||||
const allAtTop = scrollContainers.length === 0 ||
|
||||
Array.from(scrollContainers).every(
|
||||
container => container.scrollTop === 0
|
||||
);
|
||||
|
||||
if (allAtTop) {
|
||||
setIsFixedMode(false);
|
||||
console.log('🔓 恢复正常文档流模式(内部滚动到顶部)');
|
||||
}
|
||||
// 🎯 退出条件:CardHeader 超过退出点 OR 左侧列表接近顶部
|
||||
if (elementTop > exitPoint || allNearTop) {
|
||||
setIsFixedMode(false);
|
||||
console.log('🔓 恢复正常文档流模式', {
|
||||
elementTop,
|
||||
exitPoint,
|
||||
listNearTop: allNearTop,
|
||||
exitThreshold: EXIT_THRESHOLD,
|
||||
reason: elementTop > exitPoint ? 'CardHeader位置' : '左侧列表滚动'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -476,9 +535,66 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
<Badge colorScheme="blue">快讯</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
|
||||
<VStack align="end" spacing={2}>
|
||||
{/* 通知开关 */}
|
||||
<Tooltip
|
||||
label={browserPermission === 'granted'
|
||||
? '浏览器通知已开启,新事件将实时推送'
|
||||
: '开启后可接收实时事件推送通知'}
|
||||
placement="left"
|
||||
hasArrow
|
||||
>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
bg={browserPermission === 'granted'
|
||||
? useColorModeValue('green.50', 'green.900')
|
||||
: useColorModeValue('gray.50', 'gray.700')}
|
||||
borderWidth="1px"
|
||||
borderColor={browserPermission === 'granted'
|
||||
? useColorModeValue('green.200', 'green.700')
|
||||
: useColorModeValue('gray.200', 'gray.600')}
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
borderColor: browserPermission === 'granted'
|
||||
? useColorModeValue('green.300', 'green.600')
|
||||
: useColorModeValue('blue.300', 'blue.600'),
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={handleNotificationToggle}
|
||||
>
|
||||
<Icon
|
||||
as={BellIcon}
|
||||
boxSize={4}
|
||||
color={browserPermission === 'granted'
|
||||
? useColorModeValue('green.600', 'green.300')
|
||||
: useColorModeValue('gray.500', 'gray.400')}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color={browserPermission === 'granted'
|
||||
? useColorModeValue('green.700', 'green.200')
|
||||
: useColorModeValue('gray.600', 'gray.300')}
|
||||
>
|
||||
{browserPermission === 'granted' ? '通知已开启' : '开启通知'}
|
||||
</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
isChecked={browserPermission === 'granted'}
|
||||
pointerEvents="none"
|
||||
colorScheme="green"
|
||||
/>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选组件 */}
|
||||
@@ -490,6 +606,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
filters={filters}
|
||||
mode={mode}
|
||||
pageSize={pageSize}
|
||||
trackingFunctions={trackingFunctions}
|
||||
/>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
|
||||
@@ -13,21 +13,31 @@ import DynamicNewsDetailPanel from '../DynamicNewsDetail';
|
||||
* @param {string} scrollbarTrackBg - 滚动条轨道背景色
|
||||
* @param {string} scrollbarThumbBg - 滚动条滑块背景色
|
||||
* @param {string} scrollbarThumbHoverBg - 滚动条滑块悬浮背景色
|
||||
* @param {string} detailMode - 详情模式:'full' | 'no-header'(默认 'full')
|
||||
* @param {boolean} showHeader - 是否显示头部(可选,优先级高于 detailMode)
|
||||
*/
|
||||
const EventDetailScrollPanel = ({
|
||||
selectedEvent,
|
||||
scrollbarTrackBg,
|
||||
scrollbarThumbBg,
|
||||
scrollbarThumbHoverBg,
|
||||
detailMode = 'full',
|
||||
showHeader,
|
||||
}) => {
|
||||
// 计算是否显示头部:showHeader 显式指定时优先,否则根据 detailMode 判断
|
||||
const shouldShowHeader = showHeader !== undefined
|
||||
? showHeader
|
||||
: detailMode === 'full';
|
||||
return (
|
||||
<Box
|
||||
pl={2}
|
||||
position="relative"
|
||||
data-detail-panel-container="true"
|
||||
sx={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
overscrollBehavior: 'contain',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '3px',
|
||||
},
|
||||
@@ -45,7 +55,7 @@ const EventDetailScrollPanel = ({
|
||||
}}
|
||||
>
|
||||
{selectedEvent ? (
|
||||
<DynamicNewsDetailPanel event={selectedEvent} />
|
||||
<DynamicNewsDetailPanel event={selectedEvent} showHeader={shouldShowHeader} />
|
||||
) : (
|
||||
<Center h="100%" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
|
||||
@@ -33,7 +33,7 @@ const VerticalModeLayout = ({
|
||||
borderColor,
|
||||
}) => {
|
||||
// 布局模式状态:'detail' = 聚焦详情(默认),'list' = 聚焦列表
|
||||
const [layoutMode, setLayoutMode] = useState('list');
|
||||
const [layoutMode, setLayoutMode] = useState('detail');
|
||||
|
||||
// 详情面板重置 key(切换到 list 模式时改变,强制重新渲染)
|
||||
const [detailPanelKey, setDetailPanelKey] = useState(0);
|
||||
@@ -75,7 +75,7 @@ const VerticalModeLayout = ({
|
||||
minWidth={0}
|
||||
overflowY="auto"
|
||||
h="100%"
|
||||
data-scroll-container="true"
|
||||
data-event-list-container="true"
|
||||
css={{
|
||||
overscrollBehavior: 'contain',
|
||||
'&::-webkit-scrollbar': {
|
||||
@@ -112,6 +112,7 @@ const VerticalModeLayout = ({
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
|
||||
layout="vertical"
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -161,6 +162,7 @@ const VerticalModeLayout = ({
|
||||
{/* 详情面板 */}
|
||||
<EventDetailScrollPanel
|
||||
key={detailPanelKey}
|
||||
detailMode="no-header"
|
||||
selectedEvent={selectedEvent}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -73,7 +73,7 @@ const VirtualizedFourRowGrid = ({
|
||||
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
|
||||
*
|
||||
* 工作原理:
|
||||
* 1. 向下滚动到 60% 位置时,触发 loadNextPage()
|
||||
* 1. 向下滚动到 90% 位置时,触发 loadNextPage()
|
||||
* - 调用 usePagination.loadNextPage()
|
||||
* - 内部执行 handlePageChange(currentPage + 1)
|
||||
* - dispatch(fetchDynamicNews({ page: nextPage }))
|
||||
@@ -87,7 +87,7 @@ const VirtualizedFourRowGrid = ({
|
||||
* - 与5分钟定时刷新协同工作
|
||||
*
|
||||
* 设计要点:
|
||||
* - 60% 触发点:提前加载,避免滚动到底部时才出现加载状态
|
||||
* - 90% 触发点:接近底部才加载,避免过早触发影响用户体验
|
||||
* - 防抖机制:isLoadingMore.current 防止重复触发
|
||||
* - 两层缓存:
|
||||
* - Redux 缓存(HTTP层):fourRowEvents 数组存储已加载数据,避免重复请求
|
||||
@@ -107,9 +107,9 @@ const VirtualizedFourRowGrid = ({
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||
|
||||
// 向下滚动:滚动到 60% 时开始加载下一页
|
||||
if (loadNextPage && hasMore && scrollPercentage > 0.6) {
|
||||
console.log('%c📜 [无限滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
||||
// 向下滚动:滚动到 90% 时开始加载下一页(更接近底部,避免过早触发)
|
||||
if (loadNextPage && hasMore && scrollPercentage > 0.9) {
|
||||
console.log('%c📜 [无限滚动] 接近底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
||||
isLoadingMore.current = true;
|
||||
await loadNextPage();
|
||||
isLoadingMore.current = false;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Heading,
|
||||
Badge,
|
||||
IconButton,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
@@ -21,22 +22,53 @@ import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false)
|
||||
* @param {string} props.currentMode - 当前模式:'detailed' | 'simple'
|
||||
* @param {Function} props.onModeToggle - 模式切换回调
|
||||
* @param {boolean} props.isLocked - 是否锁定(不可展开)
|
||||
*/
|
||||
const CollapsibleHeader = ({ title, isOpen, onToggle, count = null, subscriptionBadge = null }) => {
|
||||
const CollapsibleHeader = ({
|
||||
title,
|
||||
isOpen,
|
||||
onToggle,
|
||||
count = null,
|
||||
subscriptionBadge = null,
|
||||
showModeToggle = false,
|
||||
currentMode = 'detailed',
|
||||
onModeToggle = null,
|
||||
isLocked = false
|
||||
}) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 获取按钮文案
|
||||
const getButtonText = () => {
|
||||
if (currentMode === 'simple') {
|
||||
return '查看详情'; // 简单模式时,按钮显示"查看详情"
|
||||
}
|
||||
return '精简模式'; // 详细模式时,按钮显示"精简模式"
|
||||
};
|
||||
|
||||
// 获取按钮图标
|
||||
const getButtonIcon = () => {
|
||||
if (currentMode === 'simple') {
|
||||
return null; // 简单模式不显示图标
|
||||
}
|
||||
// 详细模式:展开显示向上箭头,收起显示向下箭头
|
||||
return isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
cursor={showModeToggle ? 'default' : 'pointer'}
|
||||
onClick={showModeToggle ? undefined : onToggle}
|
||||
p={3}
|
||||
bg={sectionBg}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
_hover={showModeToggle ? {} : { bg: hoverBg }}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
@@ -54,12 +86,32 @@ const CollapsibleHeader = ({ title, isOpen, onToggle, count = null, subscription
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isOpen ? '收起' : '展开'}
|
||||
/>
|
||||
|
||||
{/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
|
||||
{showModeToggle && onModeToggle && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
rightIcon={getButtonIcon()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onModeToggle(e);
|
||||
}}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* showModeToggle=false 时显示原有的 IconButton */}
|
||||
{!showModeToggle && (
|
||||
<IconButton
|
||||
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isOpen ? '收起' : '展开'}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
|
||||
// 通用可折叠区块组件
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
@@ -19,7 +19,10 @@ import CollapsibleHeader from './CollapsibleHeader';
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
* @param {boolean} props.isLocked - 是否锁定(不可展开)
|
||||
* @param {Function} props.onLockedClick - 锁定时点击的回调
|
||||
* @param {React.ReactNode} props.children - 子内容
|
||||
* @param {React.ReactNode} props.children - 详细内容
|
||||
* @param {React.ReactNode} props.simpleContent - 精简模式的内容(可选)
|
||||
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false)
|
||||
* @param {string} props.defaultMode - 默认模式:'detailed' | 'simple'(默认 'detailed')
|
||||
*/
|
||||
const CollapsibleSection = ({
|
||||
title,
|
||||
@@ -29,10 +32,16 @@ const CollapsibleSection = ({
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null,
|
||||
children
|
||||
children,
|
||||
simpleContent = null,
|
||||
showModeToggle = false,
|
||||
defaultMode = 'detailed'
|
||||
}) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
|
||||
// 模式状态:'detailed' | 'simple'
|
||||
const [displayMode, setDisplayMode] = useState(defaultMode);
|
||||
|
||||
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
|
||||
const handleToggle = () => {
|
||||
if (isLocked && onLockedClick) {
|
||||
@@ -42,15 +51,43 @@ const CollapsibleSection = ({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CollapsibleHeader
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onToggle={handleToggle}
|
||||
count={count}
|
||||
subscriptionBadge={subscriptionBadge}
|
||||
/>
|
||||
// 处理模式切换
|
||||
const handleModeToggle = (e) => {
|
||||
e.stopPropagation(); // 阻止冒泡到标题栏的 onToggle
|
||||
|
||||
if (isLocked && onLockedClick) {
|
||||
// 如果被锁定,触发付费弹窗
|
||||
onLockedClick();
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayMode === 'detailed') {
|
||||
// 从详细模式切换到精简模式
|
||||
setDisplayMode('simple');
|
||||
} else {
|
||||
// 从精简模式切换回详细模式
|
||||
setDisplayMode('detailed');
|
||||
// 切换回详细模式时,如果未展开则自动展开
|
||||
if (!isOpen && onToggle) {
|
||||
onToggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染精简模式
|
||||
const renderSimpleMode = () => {
|
||||
if (!simpleContent) return null;
|
||||
|
||||
return (
|
||||
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
|
||||
{simpleContent}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染详细模式
|
||||
const renderDetailedMode = () => {
|
||||
return (
|
||||
<Collapse
|
||||
in={isOpen && !isLocked}
|
||||
animateOpacity
|
||||
@@ -61,6 +98,25 @@ const CollapsibleSection = ({
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CollapsibleHeader
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onToggle={handleToggle}
|
||||
count={count}
|
||||
subscriptionBadge={subscriptionBadge}
|
||||
showModeToggle={showModeToggle}
|
||||
currentMode={displayMode}
|
||||
onModeToggle={handleModeToggle}
|
||||
isLocked={isLocked}
|
||||
/>
|
||||
|
||||
{/* 根据当前模式渲染对应内容 */}
|
||||
{displayMode === 'simple' ? renderSimpleMode() : renderDetailedMode()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CompactMetaBar.js
|
||||
// 精简信息栏组件(无头部模式下右上角显示)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Badge,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
/**
|
||||
* 精简信息栏组件
|
||||
* 在无头部模式下,显示在 CardBody 右上角
|
||||
* 包含:重要性徽章、浏览次数、关注按钮
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @param {Object} props.importance - 重要性配置对象(包含 level, icon 等)
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {Function} props.onToggleFollow - 切换关注回调
|
||||
*/
|
||||
const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
|
||||
const viewCountBg = useColorModeValue('white', 'gray.700');
|
||||
const viewCountTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
// 获取重要性文本
|
||||
const getImportanceText = () => {
|
||||
const levelMap = {
|
||||
'S': '极高',
|
||||
'A': '高',
|
||||
'B': '中',
|
||||
'C': '低'
|
||||
};
|
||||
return levelMap[importance.level] || '中';
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack
|
||||
position="absolute"
|
||||
top={3}
|
||||
right={3}
|
||||
spacing={3}
|
||||
zIndex={1}
|
||||
>
|
||||
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 */}
|
||||
<Badge
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
bgGradient={
|
||||
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
|
||||
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
|
||||
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
|
||||
'linear(to-r, gray.500, gray.700)'
|
||||
}
|
||||
color="white"
|
||||
boxShadow="lg"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Icon as={importance.icon} boxSize={4} />
|
||||
<Text>重要性:{getImportanceText()}</Text>
|
||||
</Badge>
|
||||
|
||||
{/* 浏览次数 - 添加容器背景以提高可读性 */}
|
||||
<HStack
|
||||
spacing={1}
|
||||
bg={viewCountBg}
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<ViewIcon color="gray.400" boxSize={4} />
|
||||
<Text fontSize="sm" color={viewCountTextColor} whiteSpace="nowrap">
|
||||
{(event.view_count || 0).toLocaleString()}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={onToggleFollow}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactMetaBar;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '../../../../utils/colorUtils';
|
||||
|
||||
/**
|
||||
* 精简模式股票卡片组件
|
||||
@@ -30,31 +31,6 @@ const CompactStockItem = ({ stock, quote = null }) => {
|
||||
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌幅颜色(涨红跌绿)
|
||||
const getChangeColor = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) return 'gray.500';
|
||||
return num > 0 ? 'red.500' : 'green.500';
|
||||
};
|
||||
|
||||
// 获取背景渐变色(涨红跌绿)
|
||||
const getBackgroundGradient = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) {
|
||||
return 'linear(to-br, gray.50, gray.100)';
|
||||
}
|
||||
return num > 0
|
||||
? 'linear(to-br, red.50, red.100)'
|
||||
: 'linear(to-br, green.50, green.100)';
|
||||
};
|
||||
|
||||
// 获取边框颜色
|
||||
const getBorderColor = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) return 'gray.300';
|
||||
return num > 0 ? 'red.300' : 'green.300';
|
||||
};
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 stock)
|
||||
const change = quote?.change ?? stock.daily_change ?? null;
|
||||
|
||||
@@ -68,9 +44,9 @@ const CompactStockItem = ({ stock, quote = null }) => {
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
bgGradient={getBackgroundGradient(change)}
|
||||
bgGradient={getChangeBackgroundGradient(change)}
|
||||
borderWidth="3px"
|
||||
borderColor={getBorderColor(change)}
|
||||
borderColor={getChangeBorderColor(change)}
|
||||
borderRadius="2xl"
|
||||
p={4}
|
||||
onClick={handleViewDetail}
|
||||
@@ -85,7 +61,7 @@ const CompactStockItem = ({ stock, quote = null }) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
bg: getBorderColor(change),
|
||||
bg: getChangeBorderColor(change),
|
||||
}}
|
||||
_hover={{
|
||||
boxShadow: '2xl',
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
@@ -19,9 +21,11 @@ import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
import EventHeaderInfo from './EventHeaderInfo';
|
||||
import CompactMetaBar from './CompactMetaBar';
|
||||
import EventDescriptionSection from './EventDescriptionSection';
|
||||
import RelatedConceptsSection from './RelatedConceptsSection';
|
||||
import RelatedStocksSection from './RelatedStocksSection';
|
||||
import CompactStockItem from './CompactStockItem';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
|
||||
@@ -32,8 +36,9 @@ import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgrade
|
||||
* 动态新闻详情面板主组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象(包含详情数据)
|
||||
* @param {boolean} props.showHeader - 是否显示头部信息(默认 true)
|
||||
*/
|
||||
const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { user } = useAuth();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
@@ -49,6 +54,10 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
||||
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
|
||||
|
||||
// 🎯 浏览量机制:存储从 API 获取的完整事件详情(包含最新 view_count)
|
||||
const [fullEventDetail, setFullEventDetail] = useState(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
|
||||
// 权限判断函数
|
||||
const hasAccess = useCallback((requiredTier) => {
|
||||
const tierLevel = { free: 0, pro: 1, max: 2 };
|
||||
@@ -76,6 +85,30 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
loadChainAnalysis
|
||||
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false });
|
||||
|
||||
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
|
||||
const loadEventDetail = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const response = await eventService.getEventDetail(event.id);
|
||||
if (response.success) {
|
||||
setFullEventDetail(response.data);
|
||||
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
|
||||
eventId: event.id,
|
||||
viewCount: response.data.view_count,
|
||||
title: response.data.title
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [event?.id]);
|
||||
|
||||
// 相关股票、相关概念、历史事件和传导链的权限
|
||||
const canAccessStocks = hasAccess('pro');
|
||||
const canAccessConcepts = hasAccess('pro');
|
||||
@@ -83,8 +116,8 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const canAccessTransmission = hasAccess('max');
|
||||
|
||||
// 子区块折叠状态管理 + 加载追踪
|
||||
// PRO 会员的相关股票默认展开
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(canAccessStocks && userTier === 'pro');
|
||||
// 初始值为 false,由 useEffect 根据权限动态设置
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(false);
|
||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
|
||||
|
||||
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
|
||||
@@ -168,14 +201,21 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
useEffect(() => {
|
||||
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
|
||||
|
||||
// PRO 会员的相关股票默认展开,其他情况收起
|
||||
const shouldOpenStocks = canAccessStocks && userTier === 'pro';
|
||||
// 🎯 加载事件详情(增加浏览量)
|
||||
loadEventDetail();
|
||||
|
||||
// PRO 和 MAX 会员的相关股票默认展开,其他情况收起
|
||||
const shouldOpenStocks = canAccessStocks;
|
||||
setIsStocksOpen(shouldOpenStocks);
|
||||
setHasLoadedStocks(false);
|
||||
|
||||
// PRO 会员默认展开时,自动加载股票数据
|
||||
if (shouldOpenStocks) {
|
||||
console.log('%c📊 [PRO会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
||||
// PRO 和 MAX 会员自动加载股票数据(无论是否展开)
|
||||
const shouldLoadStocks = canAccessStocks; // PRO 或 MAX 都有权限
|
||||
if (shouldLoadStocks) {
|
||||
console.log('%c📊 [PRO/MAX会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', {
|
||||
eventId: event?.id,
|
||||
userTier
|
||||
});
|
||||
loadStocksData();
|
||||
setHasLoadedStocks(true);
|
||||
}
|
||||
@@ -185,7 +225,7 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
setHasLoadedHistorical(false);
|
||||
setIsTransmissionOpen(false);
|
||||
setHasLoadedTransmission(false);
|
||||
}, [event?.id, canAccessStocks, userTier, loadStocksData]);
|
||||
}, [event?.id, canAccessStocks, userTier, loadStocksData, loadEventDetail]);
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
@@ -247,21 +287,34 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 头部信息区 */}
|
||||
<EventHeaderInfo
|
||||
event={event}
|
||||
<CardBody position="relative">
|
||||
{/* 无头部模式:显示右上角精简信息栏 */}
|
||||
{!showHeader && (
|
||||
<CompactMetaBar
|
||||
event={fullEventDetail || event}
|
||||
importance={importance}
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) - 可配置显示/隐藏 */}
|
||||
{showHeader && (
|
||||
<EventHeaderInfo
|
||||
event={fullEventDetail || event}
|
||||
importance={importance}
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 事件描述 */}
|
||||
<EventDescriptionSection description={event.description} />
|
||||
|
||||
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
|
||||
<CollapsibleSection
|
||||
title="相关股票"
|
||||
isOpen={isStocksOpen}
|
||||
@@ -275,6 +328,27 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
})()}
|
||||
isLocked={!canAccessStocks}
|
||||
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
|
||||
showModeToggle={canAccessStocks}
|
||||
defaultMode="detailed"
|
||||
simpleContent={
|
||||
loading.stocks || loading.quotes ? (
|
||||
<Center py={2}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text ml={2} color={textColor} fontSize="sm">加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Wrap spacing={4}>
|
||||
{stocks?.map((stock, index) => (
|
||||
<WrapItem key={index}>
|
||||
<CompactStockItem
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
>
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
|
||||
@@ -162,7 +162,8 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 30,
|
||||
height: '100%',
|
||||
minHeight: '35px',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -38,9 +38,13 @@ const RelatedConceptsSection = ({
|
||||
eventTime,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null
|
||||
onLockedClick = null,
|
||||
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
|
||||
onToggle = undefined // 新增:受控模式(外部控制展开回调)
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// 使用外部 isOpen,如果没有则使用内部 useState
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -179,10 +183,8 @@ const RelatedConceptsSection = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有概念,不渲染
|
||||
if (!concepts || concepts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// 判断是否有数据
|
||||
const hasNoConcepts = !concepts || concepts.length === 0;
|
||||
|
||||
/**
|
||||
* 根据相关度获取颜色(浅色背景 + 深色文字)
|
||||
@@ -232,9 +234,12 @@ const RelatedConceptsSection = ({
|
||||
// 如果被锁定且有回调函数,触发付费弹窗
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else if (onToggle !== undefined) {
|
||||
// 受控模式:调用外部回调
|
||||
onToggle();
|
||||
} else {
|
||||
// 否则正常展开/收起
|
||||
setIsExpanded(!isExpanded);
|
||||
// 非受控模式:使用内部状态
|
||||
setInternalExpanded(!internalExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -249,30 +254,49 @@ const RelatedConceptsSection = ({
|
||||
</Box>
|
||||
|
||||
{/* 简单模式:横向卡片列表(总是显示) */}
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
{concepts.map((concept, index) => (
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
getRelevanceColor={getRelevanceColor}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
|
||||
{/* 详细模式:卡片网格(可折叠) */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
{/* 详细概念卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{hasNoConcepts ? (
|
||||
<Box mb={isExpanded ? 3 : 0}>
|
||||
{error ? (
|
||||
<Text color="red.500" fontSize="sm">{error}</Text>
|
||||
) : (
|
||||
<Text color={textColor} fontSize="sm">暂无相关概念数据</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
{concepts.map((concept, index) => (
|
||||
<DetailedConceptCard
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
getRelevanceColor={getRelevanceColor}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 详细模式:卡片网格(可折叠) */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
{hasNoConcepts ? (
|
||||
<Box py={4}>
|
||||
{error ? (
|
||||
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
|
||||
) : (
|
||||
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
/* 详细概念卡片网格 */
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{concepts.map((concept, index) => (
|
||||
<DetailedConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
|
||||
// 相关股票列表区组件(纯内容,不含标题)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Flex,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
|
||||
import React from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import StockListItem from './StockListItem';
|
||||
import CompactStockItem from './CompactStockItem';
|
||||
|
||||
/**
|
||||
* 相关股票列表区组件(纯内容部分)
|
||||
* 只负责渲染详细的股票列表,精简模式由外层 CollapsibleSection 的 simpleContent 提供
|
||||
* @param {Object} props
|
||||
* @param {Array<Object>} props.stocks - 股票数组
|
||||
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
|
||||
@@ -30,67 +22,23 @@ const RelatedStocksSection = ({
|
||||
watchlistSet = new Set(),
|
||||
onWatchlistToggle
|
||||
}) => {
|
||||
// 显示模式:'detail' 详情模式, 'compact' 精简模式
|
||||
const [viewMode, setViewMode] = useState('detail');
|
||||
|
||||
// 如果没有股票数据,不渲染
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 模式切换按钮 */}
|
||||
<Flex justify="flex-end">
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<Button
|
||||
leftIcon={<ViewIcon />}
|
||||
colorScheme={viewMode === 'detail' ? 'blue' : 'gray'}
|
||||
variant={viewMode === 'detail' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('detail')}
|
||||
>
|
||||
详情模式
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<ViewOffIcon />}
|
||||
colorScheme={viewMode === 'compact' ? 'blue' : 'gray'}
|
||||
variant={viewMode === 'compact' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('compact')}
|
||||
>
|
||||
精简模式
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
{/* 详情模式 */}
|
||||
{viewMode === 'detail' && (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{stocks.map((stock, index) => (
|
||||
<StockListItem
|
||||
key={index}
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
eventTime={eventTime}
|
||||
isInWatchlist={watchlistSet.has(stock.stock_code)}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 精简模式 */}
|
||||
{viewMode === 'compact' && (
|
||||
<Wrap spacing={4}>
|
||||
{stocks.map((stock, index) => (
|
||||
<WrapItem key={index}>
|
||||
<CompactStockItem
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)}
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{stocks.map((stock, index) => (
|
||||
<StockListItem
|
||||
key={index}
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
eventTime={eventTime}
|
||||
isInWatchlist={watchlistSet.has(stock.stock_code)}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
IconButton,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { StarIcon } from '@chakra-ui/icons';
|
||||
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
|
||||
import MiniKLineChart from './MiniKLineChart';
|
||||
import StockChartModal from '../../../../components/StockChart/StockChartModal';
|
||||
import CitedContent from '../../../../components/Citation/CitedContent';
|
||||
import { getChangeColor } from '../../../../utils/colorUtils';
|
||||
|
||||
/**
|
||||
* 股票卡片组件
|
||||
@@ -66,12 +69,7 @@ const StockListItem = ({
|
||||
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌幅颜色
|
||||
const getChangeColor = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) return 'gray.500';
|
||||
return num > 0 ? 'red.500' : 'green.500';
|
||||
};
|
||||
// 使用工具函数获取涨跌幅颜色(已从 colorUtils 导入)
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 stock)
|
||||
const change = quote?.change ?? stock.daily_change ?? null;
|
||||
@@ -108,7 +106,7 @@ const StockListItem = ({
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
overflow="visible"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
@@ -117,6 +115,8 @@ const StockListItem = ({
|
||||
right: 0,
|
||||
height: '3px',
|
||||
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
|
||||
borderTopLeftRadius: 'lg',
|
||||
borderTopRightRadius: 'lg',
|
||||
}}
|
||||
_hover={{
|
||||
boxShadow: 'lg',
|
||||
@@ -125,13 +125,13 @@ const StockListItem = ({
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */}
|
||||
<HStack spacing={3} align="stretch">
|
||||
{/* 左侧:股票名称 + 涨跌幅(垂直排列) - 收窄 */}
|
||||
<HStack spacing={3} align="center" flexWrap="wrap">
|
||||
{/* 左侧:股票代码 + 名称 + 涨跌幅(垂直排列) */}
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={1}
|
||||
minW="100px"
|
||||
maxW="120px"
|
||||
minW="110px"
|
||||
maxW="130px"
|
||||
justify="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
@@ -143,17 +143,29 @@ const StockListItem = ({
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={codeColor}
|
||||
noOfLines={1}
|
||||
cursor="pointer"
|
||||
onClick={handleViewDetail}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={codeColor}
|
||||
noOfLines={1}
|
||||
cursor="pointer"
|
||||
onClick={handleViewDetail}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={nameColor}
|
||||
noOfLines={1}
|
||||
cursor="pointer"
|
||||
onClick={handleViewDetail}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Tooltip>
|
||||
<HStack spacing={1} align="center">
|
||||
<Text
|
||||
@@ -177,123 +189,189 @@ const StockListItem = ({
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 分时图 - 固定宽度 */}
|
||||
<Box
|
||||
w="160px"
|
||||
{/* 分时图 - 紧凑高度 */}
|
||||
<VStack
|
||||
minW="150px"
|
||||
maxW="180px"
|
||||
flex="1"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('blue.100', 'blue.700')}
|
||||
borderRadius="md"
|
||||
p={2}
|
||||
px={2}
|
||||
py={1}
|
||||
bg={useColorModeValue('blue.50', 'blue.900')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
cursor="pointer"
|
||||
flexShrink={0}
|
||||
flexShrink={1}
|
||||
align="stretch"
|
||||
spacing={0}
|
||||
h="fit-content"
|
||||
_hover={{
|
||||
borderColor: useColorModeValue('blue.300', 'blue.500'),
|
||||
boxShadow: 'sm'
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={useColorModeValue('blue.700', 'blue.200')}
|
||||
mb={1}
|
||||
fontWeight="semibold"
|
||||
whiteSpace="nowrap"
|
||||
mb={0.5}
|
||||
>
|
||||
📈 分时
|
||||
</Text>
|
||||
<MiniTimelineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
<Box h="35px">
|
||||
<MiniTimelineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* K线图 - 固定宽度 */}
|
||||
<Box
|
||||
w="160px"
|
||||
{/* K线图 - 紧凑高度 */}
|
||||
<VStack
|
||||
minW="150px"
|
||||
maxW="180px"
|
||||
flex="1"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('purple.100', 'purple.700')}
|
||||
borderRadius="md"
|
||||
p={2}
|
||||
px={2}
|
||||
py={1}
|
||||
bg={useColorModeValue('purple.50', 'purple.900')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
cursor="pointer"
|
||||
flexShrink={0}
|
||||
flexShrink={1}
|
||||
align="stretch"
|
||||
spacing={0}
|
||||
h="fit-content"
|
||||
_hover={{
|
||||
borderColor: useColorModeValue('purple.300', 'purple.500'),
|
||||
boxShadow: 'sm'
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={useColorModeValue('purple.700', 'purple.200')}
|
||||
mb={1}
|
||||
fontWeight="semibold"
|
||||
whiteSpace="nowrap"
|
||||
mb={0.5}
|
||||
>
|
||||
📊 日线
|
||||
</Text>
|
||||
<MiniKLineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
<Box h="35px">
|
||||
<MiniKLineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* 关联描述(单行显示,点击展开)- 占据更多空间 */}
|
||||
{relationText && relationText !== '--' && (
|
||||
<Tooltip
|
||||
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.600"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
flex={1}
|
||||
minW={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescExpanded(!isDescExpanded);
|
||||
}}
|
||||
cursor="pointer"
|
||||
px={3}
|
||||
py={2}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.600'),
|
||||
}}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
{/* 去掉"关联描述"标题 */}
|
||||
<Collapse in={isDescExpanded} startingHeight={40}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={nameColor}
|
||||
lineHeight="1.6"
|
||||
{/* 关联描述 - 升级和降级处理 */}
|
||||
{stock.relation_desc && (
|
||||
<Box flex={1} minW={0}>
|
||||
{stock.relation_desc?.data ? (
|
||||
// 升级:带引用来源的版本 - 添加折叠功能
|
||||
<Tooltip
|
||||
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.600"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescExpanded(!isDescExpanded);
|
||||
}}
|
||||
cursor="pointer"
|
||||
px={3}
|
||||
py={2}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.600'),
|
||||
}}
|
||||
transition="background 0.2s"
|
||||
position="relative"
|
||||
>
|
||||
{relationText}
|
||||
</Text>
|
||||
</Collapse>
|
||||
{isDescExpanded && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
mt={2}
|
||||
fontStyle="italic"
|
||||
<Collapse in={isDescExpanded} startingHeight={40}>
|
||||
<CitedContent
|
||||
data={stock.relation_desc}
|
||||
title=""
|
||||
showAIBadge={true}
|
||||
containerStyle={{
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: '0',
|
||||
padding: '0',
|
||||
}}
|
||||
/>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
// 降级:纯文本版本(保留展开/收起功能)
|
||||
<Tooltip
|
||||
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.600"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescExpanded(!isDescExpanded);
|
||||
}}
|
||||
cursor="pointer"
|
||||
px={3}
|
||||
py={2}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.600'),
|
||||
}}
|
||||
transition="background 0.2s"
|
||||
position="relative"
|
||||
>
|
||||
⚠️ AI生成,仅供参考
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{/* 去掉"关联描述"标题 */}
|
||||
<Collapse in={isDescExpanded} startingHeight={40}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={nameColor}
|
||||
lineHeight="1.6"
|
||||
>
|
||||
{relationText}
|
||||
</Text>
|
||||
</Collapse>
|
||||
|
||||
{/* 提示信息 */}
|
||||
{isDescExpanded && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
mt={2}
|
||||
fontStyle="italic"
|
||||
>
|
||||
⚠️ AI生成,仅供参考
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { getChangeColor } from '../../../../utils/colorUtils';
|
||||
|
||||
// 导入子组件
|
||||
import ImportanceBadge from './ImportanceBadge';
|
||||
import ImportanceStamp from './ImportanceStamp';
|
||||
import EventFollowButton from './EventFollowButton';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
|
||||
@@ -153,43 +154,37 @@ const DynamicNewsEventCard = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据平均涨幅计算背景色(分级策略)
|
||||
* 根据平均涨幅计算背景色(分级策略)- 使用毛玻璃效果
|
||||
* @param {number} avgChange - 平均涨跌幅
|
||||
* @returns {string} Chakra UI 颜色值
|
||||
*/
|
||||
const getChangeBasedBgColor = (avgChange) => {
|
||||
// 转换为数字类型(处理可能的字符串类型数据)
|
||||
const numChange = Number(avgChange);
|
||||
|
||||
// 🔍 调试日志:排查背景色计算问题
|
||||
console.log('📊 [背景色计算]', {
|
||||
rawValue: avgChange,
|
||||
numValue: numChange,
|
||||
type: typeof avgChange,
|
||||
isNull: avgChange == null,
|
||||
isNaN: isNaN(numChange),
|
||||
title: event.title.substring(0, 30) + '...'
|
||||
});
|
||||
|
||||
// 如果没有涨跌幅数据或转换失败,使用默认的奇偶行背景
|
||||
// 如果没有涨跌幅数据,使用半透明背景
|
||||
if (avgChange == null || isNaN(numChange)) {
|
||||
return index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750');
|
||||
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
|
||||
}
|
||||
|
||||
// 根据涨跌幅分级返回背景色
|
||||
if (numChange >= 5) {
|
||||
return useColorModeValue('red.100', 'red.900');
|
||||
} else if (numChange >= 3) {
|
||||
return useColorModeValue('red.100', 'red.800');
|
||||
} else if (numChange > 0) {
|
||||
return useColorModeValue('red.50', 'red.700');
|
||||
} else if (numChange > -3) {
|
||||
return useColorModeValue('green.50', 'green.700');
|
||||
} else if (numChange > -5) {
|
||||
return useColorModeValue('green.100', 'green.800');
|
||||
} else {
|
||||
return useColorModeValue('green.100', 'green.900');
|
||||
// 根据涨跌幅分级返回半透明背景色(毛玻璃效果)
|
||||
const absChange = Math.abs(numChange);
|
||||
if (numChange > 0) {
|
||||
// 涨:红色系半透明
|
||||
if (absChange >= 9) return useColorModeValue('rgba(254, 202, 202, 0.9)', 'rgba(127, 29, 29, 0.9)');
|
||||
if (absChange >= 7) return useColorModeValue('rgba(254, 202, 202, 0.8)', 'rgba(153, 27, 27, 0.8)');
|
||||
if (absChange >= 5) return useColorModeValue('rgba(254, 226, 226, 0.8)', 'rgba(185, 28, 28, 0.8)');
|
||||
if (absChange >= 3) return useColorModeValue('rgba(254, 226, 226, 0.7)', 'rgba(220, 38, 38, 0.7)');
|
||||
return useColorModeValue('rgba(254, 242, 242, 0.7)', 'rgba(239, 68, 68, 0.7)');
|
||||
} else if (numChange < 0) {
|
||||
// 跌:绿色系半透明
|
||||
if (absChange >= 9) return useColorModeValue('rgba(187, 247, 208, 0.9)', 'rgba(20, 83, 45, 0.9)');
|
||||
if (absChange >= 7) return useColorModeValue('rgba(187, 247, 208, 0.8)', 'rgba(22, 101, 52, 0.8)');
|
||||
if (absChange >= 5) return useColorModeValue('rgba(209, 250, 229, 0.8)', 'rgba(21, 128, 61, 0.8)');
|
||||
if (absChange >= 3) return useColorModeValue('rgba(209, 250, 229, 0.7)', 'rgba(22, 163, 74, 0.7)');
|
||||
return useColorModeValue('rgba(240, 253, 244, 0.7)', 'rgba(34, 197, 94, 0.7)');
|
||||
}
|
||||
|
||||
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
|
||||
};
|
||||
|
||||
// 获取当前事件的交易时段、样式和文字标签
|
||||
@@ -198,8 +193,11 @@ const DynamicNewsEventCard = ({
|
||||
const periodLabel = getPeriodLabel(tradingPeriod);
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={2} w="100%" pt={3}>
|
||||
|
||||
<VStack align="stretch" spacing={2} w="100%" pt={8} position="relative">
|
||||
{/* 右上角:重要性印章(放在卡片外层) */}
|
||||
<Box position="absolute" top={-4} right={4} zIndex={10}>
|
||||
<ImportanceStamp importance={event.importance} />
|
||||
</Box>
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
@@ -208,17 +206,18 @@ const DynamicNewsEventCard = ({
|
||||
? useColorModeValue('blue.50', 'blue.900')
|
||||
: getChangeBasedBgColor(event.related_avg_chg)
|
||||
}
|
||||
backdropFilter="blur(10px)" // 毛玻璃效果
|
||||
borderWidth={isSelected ? "2px" : "1px"}
|
||||
borderColor={isSelected
|
||||
? useColorModeValue('blue.500', 'blue.400')
|
||||
: borderColor
|
||||
}
|
||||
borderRadius="md"
|
||||
boxShadow={isSelected ? "lg" : "sm"}
|
||||
// overflow="hidden"
|
||||
borderRadius="lg"
|
||||
boxShadow={isSelected ? "xl" : "md"}
|
||||
overflow="visible"
|
||||
_hover={{
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '2xl',
|
||||
transform: 'translateY(-4px)',
|
||||
borderColor: isSelected ? 'blue.600' : importance.color,
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
@@ -226,8 +225,6 @@ const DynamicNewsEventCard = ({
|
||||
onClick={() => onEventClick?.(event)}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
{/* 左上角:重要性标签 */}
|
||||
<ImportanceBadge importance={event.importance} position={{ top:-1, left: 0}} />
|
||||
|
||||
{/* 时间标签 - 在卡片上方,宽度自适应,左对齐 */}
|
||||
<Box
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
import ImportanceBadge from './ImportanceBadge';
|
||||
import ImportanceStamp from './ImportanceStamp';
|
||||
import EventTimeline from './EventTimeline';
|
||||
import EventFollowButton from './EventFollowButton';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
@@ -34,6 +34,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
|
||||
* @param {Object} props.timelineStyle - 时间轴样式配置
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
* @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large')
|
||||
* @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度
|
||||
*/
|
||||
const HorizontalDynamicNewsEventCard = ({
|
||||
event,
|
||||
@@ -47,6 +48,7 @@ const HorizontalDynamicNewsEventCard = ({
|
||||
timelineStyle,
|
||||
borderColor,
|
||||
indicatorSize = 'comfortable',
|
||||
layout = 'vertical',
|
||||
}) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
|
||||
@@ -57,6 +59,40 @@ const HorizontalDynamicNewsEventCard = ({
|
||||
const selectedBorderColor = useColorModeValue('blue.500', 'blue.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
|
||||
/**
|
||||
* 根据平均涨幅计算背景色(分级策略)- 使用毛玻璃效果
|
||||
* @param {number} avgChange - 平均涨跌幅
|
||||
* @returns {string} Chakra UI 颜色值
|
||||
*/
|
||||
const getChangeBasedBgColor = (avgChange) => {
|
||||
const numChange = Number(avgChange);
|
||||
|
||||
// 如果没有涨跌幅数据,使用半透明背景
|
||||
if (avgChange == null || isNaN(numChange)) {
|
||||
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
|
||||
}
|
||||
|
||||
// 根据涨跌幅分级返回半透明背景色(毛玻璃效果)
|
||||
const absChange = Math.abs(numChange);
|
||||
if (numChange > 0) {
|
||||
// 涨:红色系半透明
|
||||
if (absChange >= 9) return useColorModeValue('rgba(254, 202, 202, 0.9)', 'rgba(127, 29, 29, 0.9)');
|
||||
if (absChange >= 7) return useColorModeValue('rgba(254, 202, 202, 0.8)', 'rgba(153, 27, 27, 0.8)');
|
||||
if (absChange >= 5) return useColorModeValue('rgba(254, 226, 226, 0.8)', 'rgba(185, 28, 28, 0.8)');
|
||||
if (absChange >= 3) return useColorModeValue('rgba(254, 226, 226, 0.7)', 'rgba(220, 38, 38, 0.7)');
|
||||
return useColorModeValue('rgba(254, 242, 242, 0.7)', 'rgba(239, 68, 68, 0.7)');
|
||||
} else if (numChange < 0) {
|
||||
// 跌:绿色系半透明
|
||||
if (absChange >= 9) return useColorModeValue('rgba(187, 247, 208, 0.9)', 'rgba(20, 83, 45, 0.9)');
|
||||
if (absChange >= 7) return useColorModeValue('rgba(187, 247, 208, 0.8)', 'rgba(22, 101, 52, 0.8)');
|
||||
if (absChange >= 5) return useColorModeValue('rgba(209, 250, 229, 0.8)', 'rgba(21, 128, 61, 0.8)');
|
||||
if (absChange >= 3) return useColorModeValue('rgba(209, 250, 229, 0.7)', 'rgba(22, 163, 74, 0.7)');
|
||||
return useColorModeValue('rgba(240, 253, 244, 0.7)', 'rgba(34, 197, 94, 0.7)');
|
||||
}
|
||||
|
||||
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={3} w="full">
|
||||
{/* 左侧时间轴 */}
|
||||
@@ -64,91 +100,96 @@ const HorizontalDynamicNewsEventCard = ({
|
||||
createdAt={event.created_at}
|
||||
timelineStyle={timelineStyle}
|
||||
borderColor={borderColor}
|
||||
minHeight="60px"
|
||||
minHeight={layout === 'four-row' ? '60px' : 0}
|
||||
/>
|
||||
|
||||
{/* 右侧事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
position="relative"
|
||||
bg={isSelected
|
||||
? selectedBg
|
||||
: (index % 2 === 0 ? cardBg : cardBgAlt)
|
||||
}
|
||||
borderWidth={isSelected ? "2px" : "1px"}
|
||||
borderColor={isSelected
|
||||
? selectedBorderColor
|
||||
: borderColor
|
||||
}
|
||||
borderRadius="md"
|
||||
boxShadow={isSelected ? "lg" : "sm"}
|
||||
overflow="hidden"
|
||||
_hover={{
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: isSelected ? 'blue.600' : importance.color,
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick?.(event)}
|
||||
>
|
||||
<CardBody p={3} pb={2}>
|
||||
{/* 左上角:重要性标签 */}
|
||||
<ImportanceBadge importance={event.importance} />
|
||||
{/* 右侧事件卡片容器(带印章) */}
|
||||
<Box flex="1" position="relative" pt={6}>
|
||||
{/* 右上角:重要性印章(放在卡片外层) */}
|
||||
<Box position="absolute" top={-2} right={4} zIndex={10}>
|
||||
<ImportanceStamp importance={event.importance} size="sm" />
|
||||
</Box>
|
||||
|
||||
{/* 右上角:关注按钮 */}
|
||||
<Box position="absolute" top={2} right={2} zIndex={1}>
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={() => onToggleFollow?.(event.id)}
|
||||
size="xs"
|
||||
showCount={false}
|
||||
/>
|
||||
</Box>
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
position="relative"
|
||||
bg={isSelected
|
||||
? selectedBg
|
||||
: getChangeBasedBgColor(event.related_avg_chg)
|
||||
}
|
||||
backdropFilter="blur(10px)" // 毛玻璃效果
|
||||
borderWidth={isSelected ? "2px" : "1px"}
|
||||
borderColor={isSelected
|
||||
? selectedBorderColor
|
||||
: borderColor
|
||||
}
|
||||
borderRadius="lg"
|
||||
boxShadow={isSelected ? "xl" : "md"}
|
||||
overflow="visible"
|
||||
_hover={{
|
||||
boxShadow: '2xl',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: isSelected ? 'blue.600' : importance.color,
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick?.(event)}
|
||||
>
|
||||
<CardBody p={3} pb={2}>
|
||||
{/* 右上角:关注按钮 */}
|
||||
<Box position="absolute" top={2} right={2} zIndex={1}>
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={() => onToggleFollow?.(event.id)}
|
||||
size="xs"
|
||||
showCount={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<VStack align="stretch" spacing={1.5}>
|
||||
{/* 标题 - 最多两行,hover 显示完整内容 */}
|
||||
<Tooltip
|
||||
label={event.title}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.700"
|
||||
color="white"
|
||||
fontSize="sm"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
isDisabled={event.title.length < 40}
|
||||
>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
onClick={(e) => onTitleClick?.(e, event)}
|
||||
mt={1}
|
||||
paddingRight="10px"
|
||||
<VStack align="stretch" spacing={1.5}>
|
||||
{/* 标题 - 最多两行,hover 显示完整内容 */}
|
||||
<Tooltip
|
||||
label={event.title}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.700"
|
||||
color="white"
|
||||
fontSize="sm"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
isDisabled={event.title.length < 40}
|
||||
>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="semibold"
|
||||
color={linkColor}
|
||||
lineHeight="1.4"
|
||||
noOfLines={2}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
<Box
|
||||
cursor="pointer"
|
||||
onClick={(e) => onTitleClick?.(e, event)}
|
||||
mt={1}
|
||||
paddingRight="10px"
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="semibold"
|
||||
color={linkColor}
|
||||
lineHeight="1.4"
|
||||
noOfLines={2}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
{/* 第二行:涨跌幅数据 */}
|
||||
<StockChangeIndicators
|
||||
avgChange={event.related_avg_chg}
|
||||
maxChange={event.related_max_chg}
|
||||
weekChange={event.related_week_chg}
|
||||
size={indicatorSize}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/* 第二行:涨跌幅数据 */}
|
||||
<StockChangeIndicators
|
||||
avgChange={event.related_avg_chg}
|
||||
maxChange={event.related_max_chg}
|
||||
weekChange={event.related_week_chg}
|
||||
size={indicatorSize}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
86
src/views/Community/components/EventCard/ImportanceStamp.js
Normal file
86
src/views/Community/components/EventCard/ImportanceStamp.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// src/views/Community/components/EventCard/ImportanceStamp.js
|
||||
// 重要性印章组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
/**
|
||||
* 重要性印章组件(模拟盖章效果)
|
||||
* @param {Object} props
|
||||
* @param {string} props.importance - 重要性等级 (S/A/B/C)
|
||||
* @param {string} props.size - 印章尺寸 ('sm' | 'md' | 'lg')
|
||||
*/
|
||||
const ImportanceStamp = ({ importance, size = 'md' }) => {
|
||||
const config = getImportanceConfig(importance);
|
||||
|
||||
// 印章颜色
|
||||
const stampColor = useColorModeValue(config.badgeBg, config.badgeBg);
|
||||
|
||||
// 尺寸配置
|
||||
const sizeConfig = {
|
||||
sm: { outer: '40px', inner: '34px', fontSize: 'md', borderOuter: '2px', borderInner: '1.5px' },
|
||||
md: { outer: '50px', inner: '42px', fontSize: 'xl', borderOuter: '3px', borderInner: '2px' },
|
||||
lg: { outer: '60px', inner: '52px', fontSize: '2xl', borderOuter: '4px', borderInner: '2.5px' },
|
||||
};
|
||||
|
||||
const currentSize = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
display="inline-block"
|
||||
>
|
||||
{/* 外层圆形边框(双圈) */}
|
||||
<Box
|
||||
position="relative"
|
||||
w={currentSize.outer}
|
||||
h={currentSize.outer}
|
||||
borderRadius="50%"
|
||||
borderWidth={currentSize.borderOuter}
|
||||
borderColor={stampColor}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
transform="rotate(-15deg)"
|
||||
opacity={0.9}
|
||||
boxShadow="0 3px 12px rgba(0,0,0,0.2)"
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
_hover={{
|
||||
opacity: 1,
|
||||
transform: "rotate(-15deg) scale(1.15)",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
>
|
||||
{/* 内层圆形边框 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
w={currentSize.inner}
|
||||
h={currentSize.inner}
|
||||
borderRadius="50%"
|
||||
borderWidth={currentSize.borderInner}
|
||||
borderColor={stampColor}
|
||||
/>
|
||||
|
||||
{/* 印章文字 */}
|
||||
<Text
|
||||
fontSize={currentSize.fontSize}
|
||||
fontWeight="black"
|
||||
color={stampColor}
|
||||
fontFamily={config.stampFont}
|
||||
letterSpacing="2px"
|
||||
textShadow="0 0 2px currentColor"
|
||||
>
|
||||
{config.stampText}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportanceStamp;
|
||||
@@ -1,63 +0,0 @@
|
||||
// src/views/Community/components/EventModals.js
|
||||
// 事件弹窗组合组件(包含详情Modal和股票Drawer)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton
|
||||
} from '@chakra-ui/react';
|
||||
import EventDetailModal from './EventDetailModal';
|
||||
import StockDetailPanel from './StockDetailPanel';
|
||||
|
||||
/**
|
||||
* 事件弹窗组合组件
|
||||
* @param {Object} eventModalState - 事件详情Modal状态
|
||||
* @param {boolean} eventModalState.isOpen - 是否打开
|
||||
* @param {Function} eventModalState.onClose - 关闭回调
|
||||
* @param {Object} eventModalState.event - 事件对象
|
||||
* @param {Function} eventModalState.onEventClose - 事件关闭回调(清除状态)
|
||||
* @param {Object} stockDrawerState - 股票详情Drawer状态
|
||||
* @param {boolean} stockDrawerState.visible - 是否显示
|
||||
* @param {Object} stockDrawerState.event - 事件对象
|
||||
* @param {Function} stockDrawerState.onClose - 关闭回调
|
||||
*/
|
||||
const EventModals = ({
|
||||
eventModalState,
|
||||
stockDrawerState
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 事件详情模态框 - 使用Chakra UI Modal */}
|
||||
<Modal
|
||||
isOpen={eventModalState.isOpen}
|
||||
onClose={eventModalState.onClose}
|
||||
size="xl"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>事件详情</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<EventDetailModal
|
||||
event={eventModalState.event}
|
||||
onClose={eventModalState.onEventClose}
|
||||
/>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer */}
|
||||
<StockDetailPanel
|
||||
visible={stockDrawerState.visible}
|
||||
event={stockDrawerState.event}
|
||||
onClose={stockDrawerState.onClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventModals;
|
||||
@@ -1,83 +0,0 @@
|
||||
// src/views/Community/components/EventTimelineCard.js
|
||||
// 事件时间轴卡片组件(整合Header + Search + List)
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Box,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import EventTimelineHeader from './EventTimelineHeader';
|
||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||
import EventListSection from './EventListSection';
|
||||
|
||||
/**
|
||||
* 事件时间轴卡片组件
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Object} pagination - 分页信息
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @param {Array} popularKeywords - 热门关键词
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onSearch - 搜索回调
|
||||
* @param {Function} onSearchFocus - 搜索框获得焦点回调
|
||||
* @param {Function} onPageChange - 分页变化回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const EventTimelineCard = forwardRef(({
|
||||
events,
|
||||
loading,
|
||||
pagination,
|
||||
filters,
|
||||
popularKeywords,
|
||||
lastUpdateTime,
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
onPageChange,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<EventTimelineHeader lastUpdateTime={lastUpdateTime} />
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody>
|
||||
{/* 统一搜索组件(整合了话题、股票、行业、日期、排序、重要性、热门概念、筛选标签) */}
|
||||
<Box mb={4}>
|
||||
<UnifiedSearchBox
|
||||
onSearch={onSearch}
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 事件列表(包含Loading、Empty、List三种状态) */}
|
||||
<EventListSection
|
||||
loading={loading}
|
||||
events={events}
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
onEventClick={onEventClick}
|
||||
onViewDetail={onViewDetail}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
EventTimelineCard.displayName = 'EventTimelineCard';
|
||||
|
||||
export default EventTimelineCard;
|
||||
@@ -35,7 +35,7 @@ const CustomArrow = ({ className, style, onClick, direction }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const HotEvents = ({ events, onPageChange }) => {
|
||||
const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
||||
const [modalEvent, setModalEvent] = useState(null);
|
||||
@@ -67,6 +67,17 @@ const HotEvents = ({ events, onPageChange }) => {
|
||||
};
|
||||
|
||||
const handleCardClick = (event) => {
|
||||
// 🎯 追踪热点事件点击
|
||||
if (onEventClick) {
|
||||
onEventClick({
|
||||
eventId: event.id,
|
||||
eventTitle: event.title,
|
||||
importance: event.importance,
|
||||
source: 'hot_events_section',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
setModalEvent(event);
|
||||
onModalOpen();
|
||||
};
|
||||
|
||||
@@ -16,8 +16,9 @@ import HotEvents from './HotEvents';
|
||||
/**
|
||||
* 热点事件区域组件
|
||||
* @param {Array} events - 热点事件列表
|
||||
* @param {Function} onEventClick - 事件点击追踪回调
|
||||
*/
|
||||
const HotEventsSection = ({ events }) => {
|
||||
const HotEventsSection = ({ events, onEventClick }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
@@ -55,7 +56,11 @@ const HotEventsSection = ({ events }) => {
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardBody py={0} px={4}>
|
||||
<HotEvents events={events} onPageChange={handlePageChange} />
|
||||
<HotEvents
|
||||
events={events}
|
||||
onPageChange={handlePageChange}
|
||||
onEventClick={onEventClick}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -166,7 +166,8 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 30,
|
||||
height: '100%',
|
||||
minHeight: '35px',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -24,7 +24,8 @@ const UnifiedSearchBox = ({
|
||||
popularKeywords = [],
|
||||
filters = {},
|
||||
mode, // 显示模式(如:vertical, horizontal 等)
|
||||
pageSize // 每页显示数量
|
||||
pageSize, // 每页显示数量
|
||||
trackingFunctions = {} // PostHog 追踪函数集合
|
||||
}) => {
|
||||
|
||||
// 其他状态
|
||||
@@ -259,6 +260,16 @@ const UnifiedSearchBox = ({
|
||||
name: stockInfo.name
|
||||
});
|
||||
|
||||
// 🎯 追踪股票点击
|
||||
if (trackingFunctions.trackRelatedStockClicked) {
|
||||
trackingFunctions.trackRelatedStockClicked({
|
||||
stockCode: stockInfo.code,
|
||||
stockName: stockInfo.name,
|
||||
source: 'search_box_autocomplete',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新输入框显示
|
||||
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
|
||||
|
||||
@@ -289,6 +300,15 @@ const UnifiedSearchBox = ({
|
||||
// 转换为逗号分隔字符串传给后端(空数组表示"全部")
|
||||
const importanceStr = value.length === 0 ? 'all' : value.join(',');
|
||||
|
||||
// 🎯 追踪筛选操作
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'importance',
|
||||
filterValue: importanceStr,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({ importance: importanceStr });
|
||||
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
|
||||
@@ -309,6 +329,15 @@ const UnifiedSearchBox = ({
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪排序操作
|
||||
if (trackingFunctions.trackNewsSorted) {
|
||||
trackingFunctions.trackNewsSorted({
|
||||
sortBy: value,
|
||||
previousSortBy: sort,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({ sort: value });
|
||||
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
|
||||
@@ -328,6 +357,15 @@ const UnifiedSearchBox = ({
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪行业筛选
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'industry',
|
||||
filterValue: value?.[value.length - 1] || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({
|
||||
industry_code: value?.[value.length - 1] || ''
|
||||
@@ -347,6 +385,15 @@ const UnifiedSearchBox = ({
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪热门关键词点击
|
||||
if (trackingFunctions.trackNewsSearched) {
|
||||
trackingFunctions.trackNewsSearched({
|
||||
searchQuery: keyword,
|
||||
searchType: 'popular_keyword',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
q: keyword,
|
||||
industry_code: ''
|
||||
@@ -363,6 +410,16 @@ const UnifiedSearchBox = ({
|
||||
if (!timeConfig) {
|
||||
// 清空筛选
|
||||
setTradingTimeRange(null);
|
||||
|
||||
// 🎯 追踪时间筛选清空
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'time_range',
|
||||
filterValue: 'cleared',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
@@ -389,6 +446,16 @@ const UnifiedSearchBox = ({
|
||||
|
||||
setTradingTimeRange({ ...params, label, key });
|
||||
|
||||
// 🎯 追踪时间筛选
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'time_range',
|
||||
filterValue: label,
|
||||
timeRangeType: type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const searchParams = buildFilterParams({ ...params, mode });
|
||||
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
|
||||
@@ -411,6 +478,16 @@ const UnifiedSearchBox = ({
|
||||
industry_code: ''
|
||||
});
|
||||
|
||||
// 🎯 追踪搜索操作
|
||||
if (trackingFunctions.trackNewsSearched && inputValue) {
|
||||
trackingFunctions.trackNewsSearched({
|
||||
searchQuery: inputValue,
|
||||
searchType: 'main_search',
|
||||
filters: params,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('UnifiedSearchBox', '主搜索触发', {
|
||||
inputValue,
|
||||
params
|
||||
@@ -513,6 +590,15 @@ const UnifiedSearchBox = ({
|
||||
setImportance([]); // 改为空数组
|
||||
setTradingTimeRange(null); // 清空交易时段筛选
|
||||
|
||||
// 🎯 追踪筛选重置
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'reset',
|
||||
filterValue: 'all_filters_cleared',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 输出重置后的完整参数
|
||||
const resetParams = {
|
||||
q: '',
|
||||
@@ -580,7 +666,8 @@ const UnifiedSearchBox = ({
|
||||
|
||||
// 重要性标签(多选合并显示为单个标签)
|
||||
if (importance && importance.length > 0) {
|
||||
const importanceLabel = importance.map(imp => `${imp}级`).join(', ');
|
||||
const importanceMap = { 'S': '极高', 'A': '高', 'B': '中', 'C': '低' };
|
||||
const importanceLabel = importance.map(imp => importanceMap[imp] || imp).join(', ');
|
||||
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
|
||||
}
|
||||
|
||||
@@ -681,10 +768,10 @@ const UnifiedSearchBox = ({
|
||||
placeholder="全部"
|
||||
maxTagCount={3}
|
||||
>
|
||||
<Option value="S">S级</Option>
|
||||
<Option value="A">A级</Option>
|
||||
<Option value="B">B级</Option>
|
||||
<Option value="C">C级</Option>
|
||||
<Option value="S">极高</Option>
|
||||
<Option value="A">高</Option>
|
||||
<Option value="B">中</Option>
|
||||
<Option value="C">低</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
// src/views/Community/index.js
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
fetchPopularKeywords,
|
||||
fetchHotEvents,
|
||||
fetchDynamicNews
|
||||
fetchHotEvents
|
||||
} from '../../store/slices/communityDataSlice';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Button,
|
||||
CloseButton,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// 导入组件
|
||||
import EventTimelineCard from './components/EventTimelineCard';
|
||||
import DynamicNewsCard from './components/DynamicNewsCard';
|
||||
import HotEventsSection from './components/HotEventsSection';
|
||||
import EventModals from './components/EventModals';
|
||||
|
||||
// 导入自定义 Hooks
|
||||
import { useEventData } from './hooks/useEventData';
|
||||
@@ -26,42 +32,36 @@ import { useCommunityEvents } from './hooks/useCommunityEvents';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../lib/constants';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
const Community = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪(保留用于兼容)
|
||||
|
||||
// Redux状态
|
||||
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
|
||||
|
||||
// Chakra UI hooks
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const alertBgColor = useColorModeValue('blue.50', 'blue.900');
|
||||
const alertBorderColor = useColorModeValue('blue.200', 'blue.700');
|
||||
|
||||
// Ref:用于滚动到实时事件时间轴
|
||||
const eventTimelineRef = useRef(null);
|
||||
const hasScrolledRef = useRef(false); // 标记是否已滚动
|
||||
const containerRef = useRef(null); // 用于首次滚动到内容区域
|
||||
// Ref:用于首次滚动到内容区域
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// ⚡ 通知权限引导
|
||||
const { showCommunityGuide } = useNotification();
|
||||
const { browserPermission, requestBrowserPermission } = useNotification();
|
||||
|
||||
// Modal/Drawer状态
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
|
||||
// 通知横幅显示状态
|
||||
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
|
||||
|
||||
// 🎯 初始化Community埋点Hook
|
||||
const communityEvents = useCommunityEvents({ navigate });
|
||||
|
||||
// 自定义 Hooks
|
||||
const { filters, updateFilters, handlePageChange, handleEventClick, handleViewDetail } = useEventFilters({
|
||||
navigate,
|
||||
onEventClick: (event) => setSelectedEventForStock(event),
|
||||
eventTimelineRef
|
||||
navigate
|
||||
});
|
||||
|
||||
const { events, pagination, loading, lastUpdateTime } = useEventData(filters);
|
||||
@@ -72,28 +72,6 @@ const Community = () => {
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// 每5分钟刷新一次动态新闻(使用 prependMode 追加到头部)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
dispatch(fetchDynamicNews({
|
||||
page: 1,
|
||||
per_page: 10, // 获取最新的10条
|
||||
prependMode: true // 追加到头部,不清空缓存
|
||||
}));
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]);
|
||||
|
||||
// 🎯 PostHog 追踪:页面浏览
|
||||
// useEffect(() => {
|
||||
// track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
// timestamp: new Date().toISOString(),
|
||||
// has_hot_events: hotEvents && hotEvents.length > 0,
|
||||
// has_keywords: popularKeywords && popularKeywords.length > 0,
|
||||
// });
|
||||
// }, [track]); // 只在组件挂载时执行一次
|
||||
|
||||
// 🎯 追踪新闻列表查看(当事件列表加载完成后)
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && !loading) {
|
||||
@@ -107,17 +85,38 @@ const Community = () => {
|
||||
}
|
||||
}, [events, loading, pagination, filters]);
|
||||
|
||||
// ⚡ 首次访问社区时,延迟显示权限引导
|
||||
// ⚡ 检查通知权限状态,显示横幅提示
|
||||
useEffect(() => {
|
||||
if (showCommunityGuide) {
|
||||
const timer = setTimeout(() => {
|
||||
logger.info('Community', '显示社区权限引导');
|
||||
showCommunityGuide();
|
||||
}, 5000); // 延迟 5 秒,让用户先浏览页面
|
||||
// 延迟3秒显示,让用户先浏览页面
|
||||
const timer = setTimeout(() => {
|
||||
// 如果未授权或未请求过权限,显示横幅
|
||||
if (browserPermission !== 'granted') {
|
||||
const hasClosedBanner = localStorage.getItem('notification_banner_closed');
|
||||
if (!hasClosedBanner) {
|
||||
setShowNotificationBanner(true);
|
||||
logger.info('Community', '显示通知权限横幅');
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => clearTimeout(timer);
|
||||
}, [browserPermission]);
|
||||
|
||||
// 处理开启通知
|
||||
const handleEnableNotifications = async () => {
|
||||
const permission = await requestBrowserPermission();
|
||||
if (permission === 'granted') {
|
||||
setShowNotificationBanner(false);
|
||||
logger.info('Community', '通知权限已授予');
|
||||
}
|
||||
}, [showCommunityGuide]); // 只在组件挂载时执行一次
|
||||
};
|
||||
|
||||
// 处理关闭横幅
|
||||
const handleCloseBanner = () => {
|
||||
setShowNotificationBanner(false);
|
||||
localStorage.setItem('notification_banner_closed', 'true');
|
||||
logger.info('Community', '通知横幅已关闭');
|
||||
};
|
||||
|
||||
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
|
||||
useEffect(() => {
|
||||
@@ -136,70 +135,70 @@ const Community = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
|
||||
const scrollToTimeline = useCallback(() => {
|
||||
if (!hasScrolledRef.current && eventTimelineRef.current) {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动动画
|
||||
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
|
||||
inline: 'nearest' // 水平方向最小滚动
|
||||
});
|
||||
hasScrolledRef.current = true; // 标记已滚动
|
||||
logger.debug('Community', '用户触发搜索,滚动到实时事件时间轴');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 主内容区域 */}
|
||||
<Container ref={containerRef} maxW="1600px" pt={6} pb={8}>
|
||||
{/* 通知权限提示横幅 */}
|
||||
{showNotificationBanner && (
|
||||
<Alert
|
||||
status="info"
|
||||
variant="subtle"
|
||||
borderRadius="lg"
|
||||
mb={4}
|
||||
boxShadow="md"
|
||||
bg={alertBgColor}
|
||||
borderWidth="1px"
|
||||
borderColor={alertBorderColor}
|
||||
>
|
||||
<AlertIcon />
|
||||
<Box flex="1">
|
||||
<AlertTitle fontSize="md" mb={1}>
|
||||
开启桌面通知,不错过重要事件
|
||||
</AlertTitle>
|
||||
<AlertDescription fontSize="sm">
|
||||
即使浏览器最小化,也能第一时间接收新事件推送通知
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
<HStack spacing={2} ml={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={handleEnableNotifications}
|
||||
>
|
||||
立即开启
|
||||
</Button>
|
||||
<CloseButton
|
||||
onClick={handleCloseBanner}
|
||||
position="relative"
|
||||
/>
|
||||
</HStack>
|
||||
</Alert>
|
||||
)}
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection events={hotEvents} />
|
||||
<HotEventsSection
|
||||
events={hotEvents}
|
||||
onEventClick={communityEvents.trackNewsArticleClicked}
|
||||
/>
|
||||
|
||||
{/* 实时要闻·动态追踪 - 横向滚动 */}
|
||||
<DynamicNewsCard
|
||||
mt={6}
|
||||
filters={filters}
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
onSearch={updateFilters}
|
||||
onSearchFocus={scrollToTimeline}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
trackingFunctions={{
|
||||
trackNewsArticleClicked: communityEvents.trackNewsArticleClicked,
|
||||
trackNewsDetailOpened: communityEvents.trackNewsDetailOpened,
|
||||
trackNewsFilterApplied: communityEvents.trackNewsFilterApplied,
|
||||
trackNewsSorted: communityEvents.trackNewsSorted,
|
||||
trackNewsSearched: communityEvents.trackNewsSearched,
|
||||
trackRelatedStockClicked: communityEvents.trackRelatedStockClicked,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 实时事件 - 原纵向列表 */}
|
||||
{/* <EventTimelineCard
|
||||
ref={eventTimelineRef}
|
||||
mt={6}
|
||||
events={events}
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
filters={filters}
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
onSearch={updateFilters}
|
||||
onSearchFocus={scrollToTimeline}
|
||||
onPageChange={handlePageChange}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/> */}
|
||||
</Container>
|
||||
|
||||
{/* 事件弹窗 */}
|
||||
{/* <EventModals
|
||||
eventModalState={{
|
||||
isOpen: !!selectedEvent,
|
||||
onClose: () => setSelectedEvent(null),
|
||||
event: selectedEvent,
|
||||
onEventClose: () => setSelectedEvent(null)
|
||||
}}
|
||||
stockDrawerState={{
|
||||
visible: !!selectedEventForStock,
|
||||
event: selectedEventForStock,
|
||||
onClose: () => setSelectedEventForStock(null)
|
||||
}}
|
||||
/> */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { logger } from '../../utils/logger';
|
||||
import defaultEventImage from '../../assets/img/default-event.jpg';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -174,7 +175,7 @@ const ConceptCenter = () => {
|
||||
const [stockMarketData, setStockMarketData] = useState({});
|
||||
const [loadingStockData, setLoadingStockData] = useState(false);
|
||||
// 默认图片路径
|
||||
const defaultImage = '/assets/img/default-event.jpg';
|
||||
const defaultImage = defaultEventImage;
|
||||
|
||||
// 获取最新交易日期
|
||||
const fetchLatestTradeDate = useCallback(async () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from 'react-icons/fa';
|
||||
import { stockService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import CitedContent from '../../../components/Citation/CitedContent';
|
||||
|
||||
const HistoricalEvents = ({
|
||||
events = [],
|
||||
@@ -125,6 +126,14 @@ const HistoricalEvents = ({
|
||||
return 'green';
|
||||
};
|
||||
|
||||
// 获取相关度颜色(1-10)
|
||||
const getSimilarityColor = (similarity) => {
|
||||
if (similarity >= 8) return 'green';
|
||||
if (similarity >= 6) return 'blue';
|
||||
if (similarity >= 4) return 'orange';
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '日期未知';
|
||||
@@ -224,8 +233,8 @@ const HistoricalEvents = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 历史事件卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{/* 历史事件卡片列表 - 混合布局 */}
|
||||
<VStack spacing={3} align="stretch">
|
||||
{events.map((event) => {
|
||||
const importanceColor = getImportanceColor(event.importance);
|
||||
|
||||
@@ -235,92 +244,131 @@ const HistoricalEvents = ({
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
position="relative"
|
||||
overflow="visible"
|
||||
cursor="pointer"
|
||||
onClick={() => handleCardClick(event)}
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
|
||||
borderTopLeftRadius: 'lg',
|
||||
borderTopRightRadius: 'lg',
|
||||
}}
|
||||
_hover={{
|
||||
boxShadow: 'lg',
|
||||
borderColor: 'blue.400',
|
||||
transform: 'translateY(-2px)',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 事件名称 */}
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color={useColorModeValue('blue.600', 'blue.400')}
|
||||
noOfLines={2}
|
||||
lineHeight="1.4"
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCardClick(event);
|
||||
}}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{event.title || '未命名事件'}
|
||||
</Text>
|
||||
|
||||
{/* 日期 + Badges */}
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={textSecondary}>
|
||||
{formatDate(getEventDate(event))}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={textSecondary}>
|
||||
({getRelativeTime(getEventDate(event))})
|
||||
</Text>
|
||||
{event.relevance && (
|
||||
<Badge colorScheme="blue" size="sm">
|
||||
相关度: {event.relevance}
|
||||
</Badge>
|
||||
)}
|
||||
{event.importance && (
|
||||
<Badge colorScheme={importanceColor} size="sm">
|
||||
重要性: {event.importance}
|
||||
</Badge>
|
||||
)}
|
||||
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
|
||||
<Badge
|
||||
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
|
||||
size="sm"
|
||||
<VStack align="stretch" spacing={2} p={3}>
|
||||
{/* 顶部区域:左侧(标题+时间) + 右侧(按钮) */}
|
||||
<HStack align="flex-start" spacing={3}>
|
||||
{/* 左侧:标题 + 时间信息(允许折行) */}
|
||||
<VStack flex="1" align="flex-start" spacing={1}>
|
||||
{/* 标题 */}
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color={useColorModeValue('blue.600', 'blue.400')}
|
||||
lineHeight="1.4"
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCardClick(event);
|
||||
}}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
{event.title || '未命名事件'}
|
||||
</Text>
|
||||
|
||||
{/* 时间 + Badges(允许折行) */}
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={textSecondary}>
|
||||
{formatDate(getEventDate(event))}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={textSecondary}>
|
||||
({getRelativeTime(getEventDate(event))})
|
||||
</Text>
|
||||
{event.importance && (
|
||||
<Badge colorScheme={importanceColor} size="sm">
|
||||
重要性: {event.importance}
|
||||
</Badge>
|
||||
)}
|
||||
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
|
||||
<Badge
|
||||
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
|
||||
size="sm"
|
||||
>
|
||||
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
{event.similarity !== undefined && event.similarity !== null && (
|
||||
<Badge colorScheme={getSimilarityColor(event.similarity)} size="sm">
|
||||
相关度: {event.similarity}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:相关股票按钮 */}
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Icon as={FaChartLine} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewStocks(event);
|
||||
}}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
flexShrink={0}
|
||||
>
|
||||
相关股票
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 事件描述 */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={nameColor}
|
||||
lineHeight="1.6"
|
||||
noOfLines={4}
|
||||
>
|
||||
{getEventContent(event) ? `${getEventContent(event)}(AI合成)` : '暂无内容'}
|
||||
</Text>
|
||||
|
||||
{/* 相关股票按钮 */}
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Icon as={FaChartLine} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewStocks(event);
|
||||
}}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
width="full"
|
||||
>
|
||||
相关股票
|
||||
</Button>
|
||||
{/* 底部:描述(独占整行)- 升级和降级处理 */}
|
||||
<Box>
|
||||
{(() => {
|
||||
const content = getEventContent(event);
|
||||
// 检查是否有 data 结构(升级版本)
|
||||
if (content && typeof content === 'object' && content.data) {
|
||||
return (
|
||||
<CitedContent
|
||||
data={content}
|
||||
title=""
|
||||
showAIBadge={true}
|
||||
containerStyle={{
|
||||
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
|
||||
borderRadius: '8px',
|
||||
padding: '0',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// 降级版本:纯文本
|
||||
return (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={nameColor}
|
||||
lineHeight="1.6"
|
||||
noOfLines={2}
|
||||
>
|
||||
{content ? `${content}(AI合成)` : '暂无内容'}
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
|
||||
{/* 相关股票 Modal - 条件渲染 */}
|
||||
{stocksModalOpen && (
|
||||
|
||||
Reference in New Issue
Block a user