Compare commits

...

40 Commits

Author SHA1 Message Date
zdl
926ffa1b8f fix(socket): 保留暂存监听器,修复重连后事件监听器丢失问题
## 问题
生产环境 Socket 已连接且订阅成功,但收到事件时不触发通知:
- Socket 连接正常:`connected: true`
- 订阅成功:`已订阅 all 类型的事件推送`
- **但是 `new_event` 监听器未注册**:`_callbacks.$new_event: undefined`
- Network 面板显示后端推送的消息已到达

## 根本原因
`socketService.js` 的监听器注册机制有缺陷:

### 原始逻辑(有问题):
```javascript
// connect() 方法中
if (this.pendingListeners.length > 0) {
    this.pendingListeners.forEach(({ event, callback }) => {
        this.on(event, callback);  // 注册监听器
    });
    this.pendingListeners = [];  //  清空暂存队列
}
```

### 问题:
1. **首次连接**:监听器从 `pendingListeners` 注册到 Socket,然后清空队列
2. **Socket 重连**:`pendingListeners` 已被清空,无法重新注册监听器
3. **结果**:重连后 `new_event` 监听器丢失,事件无法触发

### 为什么会重连?
- 用户网络波动
- 服务器重启
- 浏览器从休眠恢复
- Socket.IO 底层重连机制

## 解决方案

### 修改 1:保留 `pendingListeners`(不清空)

**文件**:`src/services/socketService.js:54-69`

```javascript
// 注册所有暂存的事件监听器(保留 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}`, ...);
            callback(...args);
        };

        this.socket.on(event, wrappedCallback);
        console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
    });
    // ⚠️ 重要:不清空 pendingListeners,保留用于重连
}
```

**变更**:
-  删除:`this.pendingListeners = [];`
-  新增:直接在 `this.socket.on()` 上注册(避免递归)
-  保留:`pendingListeners` 数组,用于重连时重新注册

### 修改 2:避免重复注册

**文件**:`src/services/socketService.js:166-181`

```javascript
on(event, callback) {
    if (!this.socket) {
        // Socket 未初始化,暂存监听器(检查是否已存在,避免重复)
        const exists = this.pendingListeners.some(
            (listener) => listener.event === event && listener.callback === callback
        );

        if (!exists) {
            this.pendingListeners.push({ event, callback });
        } else {
            console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
        }
        return;
    }
    // ...
}
```

**变更**:
-  新增:检查监听器是否已存在(`event` 和 `callback` 都匹配)
-  避免:重复添加相同监听器到 `pendingListeners`

## 效果

### 修复前:
```
首次连接:  new_event 监听器注册
重连后:    new_event 监听器丢失
事件推送:  不触发通知
```

### 修复后:
```
首次连接:  new_event 监听器注册
重连后:    new_event 监听器自动重新注册
事件推送:  正常触发通知
```

## 验证步骤

部署后在浏览器 Console 执行:

```javascript
// 1. 检查监听器
window.socket.socket._callbacks.$new_event  // 应该有 1-2 个监听器

// 2. 手动断开重连
window.socket.disconnect();
setTimeout(() => window.socket.connect(), 1000);

// 3. 重连后再次检查
window.socket.socket._callbacks.$new_event  // 应该仍然有监听器

// 4. 等待后端推送事件,验证通知显示
```

## 影响范围
- 修改文件: `src/services/socketService.js`(1 个文件,2 处修改)
- 影响功能: Socket 事件监听器注册机制
- 风险等级: 低(只修改监听器管理逻辑,不改变业务代码)

## 相关 Issue
- 修复生产环境 Socket 事件不触发通知问题
- 解决 Socket 重连后监听器丢失问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 14:16:00 +08:00
zdl
eebd207276 fix(socket): 暴露 Socket 实例到 window 对象,修复生产环境事件监听器失效问题
## 问题
生产环境收到 WebSocket 消息但不触发通知:
- Network 面板显示消息已接收
- 但事件监听器未触发(事件处理函数不执行)
- 手动测试 `window.__TEST_NOTIFICATION__.testAllTypes()` 正常工作
- 诊断脚本显示 `window.socket: undefined`

## 根本原因
Socket 实例未暴露到全局作用域,导致:
1. 无法验证 NotificationContext 中的监听器是否注册在正确的 Socket 实例上
2. 可能存在多个 Socket 实例(导入的实例 vs 实际连接的实例)
3. 事件监听器注册在错误的实例上

## 解决方案
在 `src/services/socket/index.js` 中暴露 Socket 实例到 window 对象:

### 代码变更
```javascript
//  新增:暴露 Socket 实例到 window(用于调试和验证)
if (typeof window !== 'undefined') {
    window.socket = socketService;
    window.socketService = socketService;

    console.log(' Socket instance exposed to window');
    console.log('  📍 window.socket:', window.socket);
    console.log('  📍 Socket.IO instance:', window.socket?.socket);
    console.log('  📍 Connection status:', window.socket?.connected);
}
```

## 好处
1. **可调试性**: 可在浏览器 Console 直接访问 Socket 实例
2. **验证监听器**: 可检查 `window.socket.socket._callbacks` 确认监听器已注册
3. **诊断连接**: 可实时查看 `window.socket.connected` 状态
4. **手动测试**: 可通过 `window.socket.emit()` 手动触发事件

## 验证步骤
部署后在浏览器 Console 执行:
```javascript
// 1. 验证 Socket 实例已暴露
console.log(window.socket);

// 2. 检查连接状态
console.log('Connected:', window.socket.connected);

// 3. 检查监听器
console.log('Listeners:', window.socket.socket._callbacks);

// 4. 测试手动触发事件
window.socket.socket.emit('new_event', { id: 999, title: 'Test' });
```

## 影响范围
- 修改文件: `src/services/socket/index.js`(1 个文件)
- 影响范围: 仅新增调试功能,不改变业务逻辑
- 风险等级: 低(只读暴露,不修改 Socket 行为)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:59:23 +08:00
zdl
6b96744b2c fix(notification): 修复 Socket 重连后通知功能失效问题(方案2)
采用完全重构的方式解决 Socket 重连后事件监听器丢失和闭包陷阱问题。

## 核心问题
1. Socket 重连后,事件监听器被重复注册,导致监听器累积或丢失
2. 闭包陷阱:监听器捕获了过期的 addNotification 函数引用
3. 依赖循环:registerSocketEvents 依赖 addNotification,导致频繁重新创建

## 解决方案(方案2:完全重构)

### 1. 使用 Ref 存储最新函数引用
```javascript
const addNotificationRef = useRef(null);
const adaptEventToNotificationRef = useRef(null);
const isFirstConnect = useRef(true);
```

### 2. 同步最新函数到 Ref
通过 useEffect 确保 ref.current 始终指向最新的函数:
```javascript
useEffect(() => {
    addNotificationRef.current = addNotification;
}, [addNotification]);
```

### 3. 监听器只注册一次
- useEffect 依赖数组改为 `[]`(空数组)
- socket.on('new_event') 只在组件挂载时注册一次
- 监听器内部使用 `ref.current` 访问最新函数

### 4. 重连时只重新订阅
- Socket 重连后只调用 `subscribeToEvents()`
- 不再重新注册监听器(避免累积)

## 关键代码变更

### NotificationContext.js
- **新增 Ref 定义**(第 62-65 行):存储最新的回调函数引用
- **新增同步 useEffect**(第 607-615 行):保持 ref 与函数同步
- **删除 registerSocketEvents 函数**:不再需要提取事件注册逻辑
- **重构 Socket useEffect**(第 618-824 行):
  - 依赖数组: `[registerSocketEvents, toast]` → `[]`
  - 监听器注册: 只在初始化时执行一次
  - 重连处理: 只调用 `subscribeToEvents()`,不重新注册监听器
  - 防御性检查: 确保 ref 已初始化再使用

## 技术优势

### 彻底解决重复注册
-  监听器生命周期与组件绑定,只注册一次
-  Socket 重连不会触发监听器重新注册

### 避免闭包陷阱
-  `ref.current` 始终指向最新的函数
-  监听器不受 useEffect 依赖变化影响

### 简化依赖管理
-  useEffect 无依赖,不会因状态变化而重新运行
-  性能优化:减少不必要的函数创建和监听器操作

### 提升代码质量
-  逻辑更清晰:所有监听器集中在一个 useEffect
-  易于维护:依赖关系简单明了
-  详细日志:便于调试和追踪问题

## 验证测试

### 测试场景
1.  首次连接 + 接收事件 → 正常显示通知
2.  断开重连 + 接收事件 → 重连后正常接收通知
3.  多次重连 → 每次重连后通知功能正常
4.  控制台无重复注册警告

### 预期效果
- 首次连接: 显示 " 首次连接成功"
- 重连成功: 显示 "🔄 重连成功!" (不显示 "registerSocketEvents() 被调用")
- 收到事件: 根据页面可见性显示网页通知或浏览器通知

## 影响范围
- 修改文件: `src/contexts/NotificationContext.js`
- 影响功能: Socket 连接管理、事件监听、通知分发
- 兼容性: 完全向后兼容,无破坏性变更

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:35:08 +08:00
zdl
463bdbf09c feat: 添加调试 API
- 我修改 NotificationContext.js,暴露 addNotification 到 window
    - 或者在调试工具 (devtools/notificationDebugger.js) 中添加测试方法
    - 重新构建并部署
    - 可以手动触发网页通知
2025-11-11 11:45:19 +08:00
zdl
a15585c464 feat: Service Worker 注册失败修复方案
1. 使用了 window.location.origin,但 Service Worker 环境中没有 window 对象
2. 注册逻辑缺少详细的错误处理和状态检查
2025-11-11 10:57:12 +08:00
zdl
643c3db03e feat: 通知调试能力 2025-11-10 20:05:53 +08:00
zdl
8e5623d723 feat(customer-service): 集成 Bytedesk 客服系统并优化 Dify 机器人显示
## 主要变更

### 1. Dify 机器人优化
**文件**: public/index.html
-  恢复 Dify 机器人代码
-  添加显示控制逻辑:只在 /home 页面显示
-  使用 JavaScript 监听路由变化,动态控制显示/隐藏
-  保留所有样式配置

### 2. Bytedesk 客服系统集成
**文件**: src/bytedesk-integration/config/bytedesk.config.js
-  配置开发环境使用代理路径(/bytedesk-api)
-  修复 X-Frame-Options 跨域问题
-  优化 shouldShowCustomerService 逻辑:默认所有页面显示,只在 /login 隐藏
-  保留白名单模式代码作为备用方案

**文件**: src/components/GlobalComponents.js
-  集成 BytedeskWidget 组件
-  使用 shouldShowCustomerService 控制显示

### 3. 客服显示规则

**Dify 机器人**:
-  /home 页面 → 显示
-  其他页面 → 隐藏

**Bytedesk 客服**:
-  所有页面 → 显示
-  /login 页面 → 隐藏

## 已知问题

- ⚠️ Bytedesk 服务器配置 enabled: false,需要后端修改为 true
- ⚠️ 配置接口: /config/bytedesk/properties

## 测试建议

1. 访问 /home 页面,检查 Dify 机器人是否显示
2. 访问其他页面,检查 Dify 是否隐藏
3. 等待后端修改 enabled 后,测试 Bytedesk 客服功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 19:58:36 +08:00
zdl
57b4841b4c feat: 添加客服组件 2025-11-10 19:23:25 +08:00
zdl
9e23b370fe feat: 底部UI调整 2025-11-10 14:48:28 +08:00
zdl
34bc3d1d6f feat: 调整footer间距 2025-11-10 14:48:28 +08:00
7f2a4dd36a 事件中心不提示通知修复 2025-11-10 14:20:42 +08:00
45ff13f4d0 事件中心不提示通知修复 2025-11-10 13:46:34 +08:00
a00b8bb73d 事件中心ui 2025-11-10 12:45:34 +08:00
46ba421f42 事件中心ui 2025-11-10 12:32:14 +08:00
6cd300b5ae 事件中心ui 2025-11-10 12:22:21 +08:00
617300ac8f 事件中心不提示通知修复 2025-11-10 10:47:39 +08:00
25163789ca 事件中心不提示通知修复,增加开启/关闭通知按钮。修复edge或者opera浏览器登录扫码无跳转的问题 2025-11-10 10:36:29 +08:00
fbf6813615 事件中心有引用的相关详情样式调整 2025-11-10 10:18:55 +08:00
800151771c agent功能开发增加MCP后端 2025-11-10 08:14:53 +08:00
9a723f04f1 agent功能开发增加MCP后端 2025-11-10 07:56:52 +08:00
2756e6e379 agent功能开发增加MCP后端 2025-11-08 11:32:01 +08:00
87d8b25768 agent功能开发增加MCP后端 2025-11-08 10:58:16 +08:00
6228bef5ad agent功能开发增加MCP后端 2025-11-08 10:17:48 +08:00
dff37adbbc agent功能开发增加MCP后端 2025-11-08 08:58:30 +08:00
2a228c8d6c agent功能开发增加MCP后端 2025-11-08 00:11:36 +08:00
95eb86c06a agent功能开发增加MCP后端 2025-11-07 23:51:18 +08:00
6899b9d0d2 agent功能开发增加MCP后端 2025-11-07 23:18:20 +08:00
a8edb8bde3 agent功能开发增加MCP后端 2025-11-07 23:03:22 +08:00
d8dc79d32c agent功能开发增加MCP后端 2025-11-07 22:45:46 +08:00
e29f391f10 agent功能开发增加MCP后端 2025-11-07 22:31:07 +08:00
30788648af agent功能开发增加MCP后端 2025-11-07 22:12:23 +08:00
c886d78ff6 agent功能开发增加MCP后端 2025-11-07 22:02:21 +08:00
3a058fd805 agent功能开发增加MCP后端 2025-11-07 21:46:50 +08:00
d1d8d1a25d agent功能开发增加MCP后端 2025-11-07 21:03:24 +08:00
fc5d2058c4 agent功能开发增加MCP后端 2025-11-07 20:50:16 +08:00
322b1dd845 agent功能开发增加MCP后端 2025-11-07 20:23:54 +08:00
zdl
f01eff6eb7 feat: 优化股票卡片显示
d670b0a feat: 历史股票增加相关度数据
     02c03ab feat: 修改列表默认状态
     8bdc2aa feat: 处理mock数据
2025-11-07 20:05:14 +08:00
zdl
4860cac3ca feat: 历史股票增加相关度数据 2025-11-07 20:05:14 +08:00
zdl
207701bbde feat: 修改列表默认状态 2025-11-07 20:05:14 +08:00
zdl
033f29e90c feat: 处理mock数据 2025-11-07 20:05:14 +08:00
51 changed files with 12584 additions and 3315 deletions

View File

@@ -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,18 @@ TSC_COMPILE_ON_ERROR=true
IMAGE_INLINE_SIZE_LIMIT=10000
# Node.js 内存限制(适用于大型项目)
NODE_OPTIONS=--max_old_space_size=4096
# ========================================
# Bytedesk 客服系统配置
# ========================================
# Bytedesk 服务器地址
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# 组织 ID从管理后台获取
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组 ID从管理后台获取
REACT_APP_BYTEDESK_SID=df_wg_aftersales
# 客服类型2=人工客服, 1=机器人)
REACT_APP_BYTEDESK_TYPE=2

View 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专注于非客服功能
- 建议在单独的分支中开发客服功能

1630
CLAUDE.md

File diff suppressed because it is too large Load Diff

5958
app_vx.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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
# 参数验证和日期范围计算

View File

@@ -110,6 +110,9 @@ module.exports = {
...webpackConfig.resolve,
alias: {
...webpackConfig.resolve.alias,
// 强制 'debug' 模块解析到 node_modules避免与 src/devtools/ 冲突)
'debug': path.resolve(__dirname, 'node_modules/debug'),
// 根目录别名
'@': path.resolve(__dirname, 'src'),
@@ -119,6 +122,7 @@ module.exports = {
'@constants': path.resolve(__dirname, 'src/constants'),
'@contexts': path.resolve(__dirname, 'src/contexts'),
'@data': path.resolve(__dirname, 'src/data'),
'@devtools': path.resolve(__dirname, 'src/devtools'),
'@hooks': path.resolve(__dirname, 'src/hooks'),
'@layouts': path.resolve(__dirname, 'src/layouts'),
'@lib': path.resolve(__dirname, 'src/lib'),
@@ -263,6 +267,34 @@ module.exports = {
logLevel: 'debug',
pathRewrite: { '^/concept-api': '' },
},
'/bytedesk-api': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
pathRewrite: { '^/bytedesk-api': '' },
},
'/chat': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
// 不需要pathRewrite保留/chat路径
},
'/config': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
// 不需要pathRewrite保留/config路径
},
'/visitor': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
// 不需要pathRewrite保留/visitor路径
},
},
}),
},

View File

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

View File

@@ -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 连接。
### 数据流
```

View File

@@ -1,8 +1,10 @@
# 实时消息推送系统 - 完整技术文档
> **版本**: v2.11.0
> **更新日期**: 2025-01-07
> **更新日期**: 2025-01-10
> **文档类型**: 快速入门 + 完整技术规格
>
> ⚠️ **重要更新**: Mock Socket 已移除2025-01-10文档中关于 `mockSocketService` 的内容仅供历史参考。
---

View File

@@ -544,3 +544,240 @@ async def get_stock_comparison(
"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
View 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()

File diff suppressed because it is too large Load Diff

View File

@@ -95,10 +95,10 @@
"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",

View File

@@ -65,6 +65,9 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<!-- ============================================
Dify 机器人配置 - 只在 /home 页面显示
============================================ -->
<script>
window.difyChatbotConfig = {
token: 'DwN8qAKtYFQtWskM',
@@ -85,6 +88,44 @@
},
}
</script>
<!-- Dify 机器人显示控制脚本 -->
<script>
// 控制 Dify 机器人只在 /home 页面显示
function controlDifyVisibility() {
const currentPath = window.location.pathname;
const difyChatButton = document.getElementById('dify-chatbot-bubble-button');
if (difyChatButton) {
// 只在 /home 页面显示
if (currentPath === '/home') {
difyChatButton.style.display = 'flex';
console.log('[Dify] 显示机器人(当前路径: /home');
} else {
difyChatButton.style.display = 'none';
console.log('[Dify] 隐藏机器人(当前路径:', currentPath, '');
}
}
}
// 页面加载完成后执行
window.addEventListener('load', function() {
console.log('[Dify] 初始化显示控制');
// 初始检查(延迟执行,等待 Dify 按钮渲染)
setTimeout(controlDifyVisibility, 500);
setTimeout(controlDifyVisibility, 1500);
// 监听路由变化React Router 使用 pushState
const observer = setInterval(controlDifyVisibility, 1000);
// 清理函数(可选)
window.addEventListener('beforeunload', function() {
clearInterval(observer);
});
});
</script>
<script
src="https://app.valuefrontier.cn/embed.min.js"
id="DwN8qAKtYFQtWskM"
@@ -166,7 +207,7 @@
bottom: 80px !important;
left: 10px !important;
}
#dify-chatbot-bubble-button {
width: 56px !important;
height: 56px !important;

View File

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

View File

@@ -0,0 +1,156 @@
################################################################################
# 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组织和工作组配置必需
# ============================================================================
# 组织IDOrganization UID
# 获取方式: 登录管理后台 -> 设置 -> 组织信息 -> 复制UID
# 示例: df_org_uid
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组IDWorkgroup SID
# 获取方式: 登录管理后台 -> 客服管理 -> 工作组 -> 复制工作组ID
# 示例: df_wg_aftersales (售后服务组)
REACT_APP_BYTEDESK_SID=df_wg_aftersales
# ============================================================================
# 可选配置
# ============================================================================
# 客服类型
# 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

View 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已在后端配置允许您的前端域名
* - 在管理后台配置正确的工作组IDsid
*/

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

View File

@@ -0,0 +1,155 @@
/**
* Bytedesk客服配置文件
* 指向43.143.189.195服务器
*
* 环境变量配置(.env文件:
* REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
* REACT_APP_BYTEDESK_ORG=df_org_uid
* REACT_APP_BYTEDESK_SID=df_wg_aftersales
*/
// 从环境变量读取配置
const BYTEDESK_API_URL = process.env.REACT_APP_BYTEDESK_API_URL || 'http://43.143.189.195';
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_aftersales';
/**
* Bytedesk客服基础配置
*/
export const bytedeskConfig = {
// API服务地址
apiUrl: BYTEDESK_API_URL,
// 聊天页面地址
htmlUrl: `${BYTEDESK_API_URL}/chat/`,
// 客服图标位置
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: '2', // 类型: 2=客服, 1=机器人
sid: BYTEDESK_SID, // 工作组ID
},
};
/**
* 获取Bytedesk配置根据环境自动切换
*
* @returns {Object} Bytedesk配置对象
*/
export const getBytedeskConfig = () => {
// 开发环境使用代理(绕过 X-Frame-Options 限制)
if (process.env.NODE_ENV === 'development') {
return {
...bytedeskConfig,
apiUrl: '/bytedesk-api', // 使用 CRACO 代理路径
htmlUrl: '/bytedesk-api/chat/', // 使用 CRACO 代理路径
};
}
// 生产环境使用完整 URL
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,
};

View File

@@ -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()}
/>
) : (
/* 未获取:显示占位符 */

View File

@@ -95,7 +95,7 @@ export const ChatInterfaceV2 = () => {
});
};
// 发送消息Agent模式
// 发送消息Agent模式 - 流式
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
@@ -106,10 +106,16 @@ export const ChatInterfaceV2 = () => {
};
addMessage(userMessage);
const userInput = inputValue; // 保存输入值
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
// 用于存储步骤结果
let currentPlan = null;
let stepResults = [];
let executingMessageId = null;
try {
// 1. 显示思考状态
addMessage({
@@ -120,18 +126,40 @@ export const ChatInterfaceV2 = () => {
setCurrentProgress(10);
// Agent API
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat`, {
// 使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: inputValue,
conversation_history: messages.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE).map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
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,
})),
}),
});
@@ -139,62 +167,139 @@ export const ChatInterfaceV2 = () => {
throw new Error('Agent请求失败');
}
const agentResponse = await response.json();
logger.info('Agent response', agentResponse);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 移除思考消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
// 读取流式数据
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!agentResponse.success) {
throw new Error(agentResponse.message || '处理失败');
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop(); // 保留不完整的行
setCurrentProgress(30);
for (const line of lines) {
if (!line.trim()) continue;
// 2. 显示执行计划
if (agentResponse.plan) {
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: agentResponse.plan,
timestamp: new Date().toISOString(),
});
}
// 解析 SSE 消息
const eventMatch = line.match(/^event: (.+)$/m);
const dataMatch = line.match(/^data: (.+)$/m);
setCurrentProgress(40);
if (!eventMatch || !dataMatch) continue;
// 3. 显示执行过程
if (agentResponse.step_results && agentResponse.step_results.length > 0) {
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: agentResponse.plan,
stepResults: agentResponse.step_results,
timestamp: new Date().toISOString(),
});
const event = eventMatch[1];
const data = JSON.parse(dataMatch[1]);
// 模拟进度更新
for (let i = 0; i < agentResponse.step_results.length; i++) {
setCurrentProgress(40 + (i + 1) / agentResponse.step_results.length * 50);
await new Promise(resolve => setTimeout(resolve, 100));
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);
}
}
}
setCurrentProgress(100);
// 移除执行中消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
// 4. 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: agentResponse.message || agentResponse.final_summary,
plan: agentResponse.plan,
stepResults: agentResponse.step_results,
metadata: agentResponse.metadata,
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error('Agent chat error', error);

View 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"
/>
);
};

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -9,6 +9,23 @@ import './styles/brainwave-colors.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 冲突)
@@ -27,24 +44,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 {

View File

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

View File

@@ -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()
}
];
// 生成带引用标记的contentdata结构
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'],
['新能源汽车', '锂电池', '充电桩'],

View File

@@ -157,8 +157,8 @@ export const routeConfig = [
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: 'AI投资助手',
description: '基于MCP的智能投资顾问'
title: '价小前投研',
description: '北京价值前沿科技公司的AI投研聊天助手'
}
},
];

View File

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

View File

@@ -1,916 +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; // 是否模拟连接失败
this.pushPaused = 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 = [];
this.pushPaused = false; // 重置暂停状态
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(() => {
// 检查是否暂停推送
if (this.pushPaused) {
logger.info('mockSocketService', '⏸️ Mock push is paused, skipping this cycle...');
return;
}
// 随机选择 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 = [];
this.pushPaused = false; // 重置暂停状态
logger.info('mockSocketService', 'Mock push stopped');
}
/**
* 暂停自动推送(保持连接和定时器运行)
*/
pausePush() {
this.pushPaused = true;
logger.info('mockSocketService', '⏸️ Mock push paused (connection and intervals maintained)');
}
/**
* 恢复自动推送
*/
resumePush() {
this.pushPaused = false;
logger.info('mockSocketService', '▶️ Mock push resumed');
}
/**
* 查询推送暂停状态
* @returns {boolean} 是否已暂停
*/
isPushPaused() {
return this.pushPaused;
}
/**
* 手动触发一条测试消息
* @param {object} customData - 自定义消息数据(可选)
*/
sendTestNotification(customData = null) {
// 如果传入自定义数据,直接使用(向后兼容)
if (customData) {
this.emit('new_event', customData);
logger.info('mockSocketService', 'Custom test notification sent', customData);
return;
}
// 默认发送新格式的测试通知(符合当前通知系统规范)
const notification = {
type: 'announcement', // 公告通知类型
priority: 'important', // 重要优先级30秒自动关闭
title: '🧪 测试通知',
content: '这是一条手动触发的测试消息,用于验证通知系统是否正常工作',
publishTime: Date.now(),
pushTime: Date.now(),
id: `test_${Date.now()}`,
clickable: false,
};
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;
},
// 暂停自动推送(保持连接)
pausePush: () => {
mockSocketService.pausePush();
logger.info('mockSocketService', '⏸️ Auto push paused');
return true;
},
// 恢复自动推送
resumePush: () => {
mockSocketService.resumePush();
logger.info('mockSocketService', '▶️ Auto push resumed');
return true;
},
// 查看推送暂停状态
isPushPaused: () => {
const paused = mockSocketService.isPushPaused();
logger.info('mockSocketService', `Push status: ${paused ? '⏸️ Paused' : '▶️ Active'}`);
return paused;
},
};
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() - 查看重连次数');
logger.info('mockSocketService', ' __mockSocket.pausePush() - ⏸️ 暂停自动推送(保持连接)');
logger.info('mockSocketService', ' __mockSocket.resumePush() - ▶️ 恢复自动推送');
logger.info('mockSocketService', ' __mockSocket.isPushPaused() - 查看推送状态');
}
export default mockSocketService;

View File

@@ -1,364 +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,
env: {
NODE_ENV: process.env.NODE_ENV,
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
REACT_APP_ENV: process.env.REACT_APP_ENV,
},
};
console.log('[Socket Diagnostics]', diagnostics);
return diagnostics;
},
// 手动订阅事件
subscribe: (options = {}) => {
const { eventType = 'all', importance = 'all' } = options;
console.log(`[Socket Debug] Subscribing to events: type=${eventType}, importance=${importance}`);
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType,
importance,
onNewEvent: (event) => {
console.log('[Socket Debug] ✅ New event received:', event);
},
onSubscribed: (data) => {
console.log('[Socket Debug] ✅ Subscription confirmed:', data);
},
});
} else {
console.error('[Socket Debug] ❌ subscribeToEvents method not available');
}
},
// 测试连接质量
testConnection: () => {
console.log('[Socket Debug] Testing connection...');
const start = Date.now();
if (socket.emit) {
socket.emit('ping', { timestamp: start }, (response) => {
const latency = Date.now() - start;
console.log(`[Socket Debug] ✅ Connection OK - Latency: ${latency}ms`, response);
});
} else {
console.error('[Socket Debug] ❌ Cannot test connection - socket.emit not available');
}
},
// 检查配置是否正确
checkConfig: () => {
const config = {
socketType: SOCKET_TYPE,
useMock,
envVars: {
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
NODE_ENV: process.env.NODE_ENV,
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
},
socketMethods: {
connect: typeof socket.connect,
disconnect: typeof socket.disconnect,
on: typeof socket.on,
emit: typeof socket.emit,
subscribeToEvents: typeof socket.subscribeToEvents,
},
};
console.log('[Socket Debug] Configuration Check:', config);
// 检查潜在问题
const issues = [];
if (SOCKET_TYPE === 'MOCK' && process.env.NODE_ENV === 'production') {
issues.push('⚠️ WARNING: Using MOCK socket in production!');
}
if (!socket.subscribeToEvents) {
issues.push('❌ ERROR: subscribeToEvents method missing');
}
if (issues.length > 0) {
console.warn('[Socket Debug] Issues found:', issues);
} else {
console.log('[Socket Debug] ✅ No issues found');
}
return { config, issues };
},
};
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;'
);
console.log(
'%c window.__SOCKET_DEBUG__.checkConfig() - 检查配置',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.subscribe() - 手动订阅事件',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.testConnection() - 测试连接',
'color: #2196F3;'
);
// ========== 通知系统专用调试 API ==========
window.__NOTIFY_DEBUG__ = {
// 完整检查(配置+连接+订阅状态)
checkAll: () => {
console.log('\n==========【通知系统诊断】==========');
// 1. 检查 Socket 配置
const socketCheck = window.__SOCKET_DEBUG__.checkConfig();
console.log('\n✓ Socket 配置检查完成');
// 2. 检查连接状态
const status = window.__SOCKET_DEBUG__.getStatus();
console.log('\n✓ 连接状态:', status.connected ? '✅ 已连接' : '❌ 未连接');
// 3. 检查环境变量
console.log('\n✓ API Base:', process.env.REACT_APP_API_URL || '(使用相对路径)');
// 4. 检查浏览器通知权限
const browserPermission = Notification?.permission || 'unsupported';
console.log('\n✓ 浏览器通知权限:', browserPermission);
// 5. 汇总报告
const report = {
timestamp: new Date().toISOString(),
socket: {
type: SOCKET_TYPE,
connected: status.connected,
reconnectAttempts: status.reconnectAttempts,
},
env: socketCheck.config.envVars,
browserNotification: browserPermission,
issues: socketCheck.issues,
};
console.log('\n========== 诊断报告 ==========');
console.table(report);
if (report.issues.length > 0) {
console.warn('\n⚠ 发现问题:', report.issues);
} else {
console.log('\n✅ 系统正常,未发现问题');
}
// 提供修复建议
if (!status.connected) {
console.log('\n💡 修复建议:');
console.log(' 1. 检查网络连接');
console.log(' 2. 尝试手动重连: __SOCKET_DEBUG__.reconnect()');
console.log(' 3. 检查后端服务是否运行');
}
if (browserPermission === 'denied') {
console.log('\n💡 浏览器通知已被拒绝,请在浏览器设置中允许通知权限');
}
console.log('\n====================================\n');
return report;
},
// 手动订阅事件(简化版)
subscribe: (eventType = 'all', importance = 'all') => {
console.log(`\n[通知调试] 手动订阅事件: type=${eventType}, importance=${importance}`);
window.__SOCKET_DEBUG__.subscribe({ eventType, importance });
},
// 模拟接收通知用于测试UI
testNotify: (type = 'announcement') => {
console.log('\n[通知调试] 模拟通知:', type);
const mockNotifications = {
announcement: {
id: `test_${Date.now()}`,
type: 'announcement',
priority: 'important',
title: '🧪 测试公告通知',
content: '这是一条测试消息,用于验证通知系统是否正常工作',
publishTime: Date.now(),
pushTime: Date.now(),
},
stock_alert: {
id: `test_${Date.now()}`,
type: 'stock_alert',
priority: 'urgent',
title: '🧪 测试股票预警',
content: '贵州茅台触发价格预警: 1850.00元 (+5.2%)',
publishTime: Date.now(),
pushTime: Date.now(),
},
event_alert: {
id: `test_${Date.now()}`,
type: 'event_alert',
priority: 'important',
title: '🧪 测试事件动向',
content: 'AI大模型新政策发布影响科技板块',
publishTime: Date.now(),
pushTime: Date.now(),
},
analysis_report: {
id: `test_${Date.now()}`,
type: 'analysis_report',
priority: 'normal',
title: '🧪 测试分析报告',
content: '2024年Q1市场策略报告已发布',
publishTime: Date.now(),
pushTime: Date.now(),
},
};
const notification = mockNotifications[type] || mockNotifications.announcement;
// 触发 new_event 事件
if (socket.emit) {
// 对于真实 Socket模拟服务端推送实际上客户端无法这样做仅用于Mock模式
console.warn('⚠️ 真实 Socket 无法模拟服务端推送,请使用 Mock 模式或等待真实推送');
}
// 直接触发事件监听器(如果是 Mock 模式)
if (SOCKET_TYPE === 'MOCK' && socket.emit) {
socket.emit('new_event', notification);
console.log('✅ 已触发 Mock 通知事件');
}
console.log('通知数据:', notification);
return notification;
},
// 导出完整诊断报告
exportReport: () => {
const report = window.__NOTIFY_DEBUG__.checkAll();
// 生成可下载的 JSON
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `notification-debug-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('✅ 诊断报告已导出');
return report;
},
// 快捷帮助
help: () => {
console.log('\n========== 通知系统调试 API ==========');
console.log('window.__NOTIFY_DEBUG__.checkAll() - 完整诊断检查');
console.log('window.__NOTIFY_DEBUG__.subscribe() - 手动订阅事件');
console.log('window.__NOTIFY_DEBUG__.testNotify(type) - 模拟通知 (announcement/stock_alert/event_alert/analysis_report)');
console.log('window.__NOTIFY_DEBUG__.exportReport() - 导出诊断报告');
console.log('\n========== Socket 调试 API ==========');
console.log('window.__SOCKET_DEBUG__.getStatus() - 获取连接状态');
console.log('window.__SOCKET_DEBUG__.checkConfig() - 检查配置');
console.log('window.__SOCKET_DEBUG__.reconnect() - 手动重连');
console.log('====================================\n');
},
};
console.log(
'%c[Notify Debug] Notification Debug API available at window.__NOTIFY_DEBUG__',
'color: #FF9800; font-weight: bold;'
);
console.log(
'%cTry: window.__NOTIFY_DEBUG__.checkAll() - 完整诊断',
'color: #FF9800;'
);
console.log(
'%c window.__NOTIFY_DEBUG__.help() - 查看所有命令',
'color: #FF9800;'
);
}
export default socket;

View File

@@ -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);
},
});
// 返回取消订阅的清理函数

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -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';
@@ -73,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);
@@ -82,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 读取关注状态
@@ -146,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);
@@ -511,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>
{/* 搜索和筛选组件 */}

View File

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

View File

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

View File

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

View File

@@ -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,89 +189,137 @@ 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>
{/* 关联描述 - 升级和降级处理 */}
{stock.relation_desc && (
<Box flex={1} minW={0}>
{stock.relation_desc?.data ? (
// 升级:带引用来源的版本
<CitedContent
data={stock.relation_desc}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
borderRadius: '8px',
padding: '0',
}}
/>
// 升级:带引用来源的版本 - 添加折叠功能
<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"
>
<Collapse in={isDescExpanded} startingHeight={40}>
<CitedContent
data={stock.relation_desc}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: 'transparent',
borderRadius: '0',
padding: '0',
}}
/>
</Collapse>
</Box>
</Tooltip>
) : (
// 降级:纯文本版本(保留展开/收起功能)
<Tooltip

View File

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

View File

@@ -1,5 +1,5 @@
// src/views/Community/index.js
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import {
@@ -10,6 +10,15 @@ import {
Box,
Container,
useColorModeValue,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
CloseButton,
HStack,
VStack,
Text,
} from '@chakra-ui/react';
// 导入组件
@@ -40,7 +49,10 @@ const Community = () => {
const containerRef = useRef(null);
// ⚡ 通知权限引导
const { showCommunityGuide } = useNotification();
const { browserPermission, requestBrowserPermission } = useNotification();
// 通知横幅显示状态
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
// 🎯 初始化Community埋点Hook
const communityEvents = useCommunityEvents({ navigate });
@@ -71,17 +83,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(() => {
@@ -104,6 +137,42 @@ const Community = () => {
<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={useColorModeValue('blue.50', 'blue.900')}
borderWidth="1px"
borderColor={useColorModeValue('blue.200', 'blue.700')}
>
<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}
@@ -112,7 +181,6 @@ const Community = () => {
{/* 实时要闻·动态追踪 - 横向滚动 */}
<DynamicNewsCard
mt={6}
filters={filters}
popularKeywords={popularKeywords}
lastUpdateTime={lastUpdateTime}

View File

@@ -126,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 '日期未知';
@@ -300,6 +308,11 @@ const HistoricalEvents = ({
涨幅: {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>