采用完全重构的方式解决 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>
1056 lines
45 KiB
JavaScript
1056 lines
45 KiB
JavaScript
// src/contexts/NotificationContext.js
|
||
/**
|
||
* 通知上下文 - 管理实时消息推送和通知显示
|
||
*
|
||
* 使用真实 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 from '../services/socket';
|
||
import notificationSound from '../assets/sounds/notification.wav';
|
||
import { browserNotificationService } from '../services/browserNotificationService';
|
||
import { notificationMetricsService } from '../services/notificationMetricsService';
|
||
import { notificationHistoryService } from '../services/notificationHistoryService';
|
||
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
|
||
import { usePermissionGuide, GUIDE_TYPES } from '../hooks/usePermissionGuide';
|
||
|
||
// 连接状态枚举
|
||
const CONNECTION_STATUS = {
|
||
CONNECTED: 'connected',
|
||
DISCONNECTED: 'disconnected',
|
||
RECONNECTING: 'reconnecting',
|
||
FAILED: 'failed',
|
||
RECONNECTED: 'reconnected', // 重连成功(显示2秒后自动变回 CONNECTED)
|
||
};
|
||
|
||
// 创建通知上下文
|
||
const NotificationContext = createContext();
|
||
|
||
// 自定义Hook
|
||
export const useNotification = () => {
|
||
const context = useContext(NotificationContext);
|
||
if (!context) {
|
||
throw new Error('useNotification must be used within a NotificationProvider');
|
||
}
|
||
return context;
|
||
};
|
||
|
||
// 通知提供者组件
|
||
export const NotificationProvider = ({ children }) => {
|
||
const toast = useToast();
|
||
const [notifications, setNotifications] = useState([]);
|
||
const [isConnected, setIsConnected] = useState(false);
|
||
const [soundEnabled, setSoundEnabled] = useState(true);
|
||
const [browserPermission, setBrowserPermission] = useState(browserNotificationService.getPermissionStatus());
|
||
const [hasRequestedPermission, setHasRequestedPermission] = useState(() => {
|
||
// 从 localStorage 读取是否已请求过权限
|
||
return localStorage.getItem('browser_notification_requested') === 'true';
|
||
});
|
||
const [connectionStatus, setConnectionStatus] = useState(CONNECTION_STATUS.CONNECTED);
|
||
const [reconnectAttempt, setReconnectAttempt] = useState(0);
|
||
const [maxReconnectAttempts, setMaxReconnectAttempts] = useState(Infinity);
|
||
const audioRef = useRef(null);
|
||
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();
|
||
|
||
// 初始化音频
|
||
useEffect(() => {
|
||
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');
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
/**
|
||
* 播放通知音效
|
||
*/
|
||
const playNotificationSound = useCallback(() => {
|
||
if (!soundEnabled || !audioRef.current) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 重置音频到开始位置
|
||
audioRef.current.currentTime = 0;
|
||
// 播放音频
|
||
audioRef.current.play().catch(error => {
|
||
logger.warn('NotificationContext', 'Failed to play notification sound', error);
|
||
});
|
||
} catch (error) {
|
||
logger.error('NotificationContext', 'playNotificationSound', error);
|
||
}
|
||
}, [soundEnabled]);
|
||
|
||
/**
|
||
* 移除通知
|
||
* @param {string} id - 通知ID
|
||
* @param {boolean} wasClicked - 是否是因为点击而关闭
|
||
*/
|
||
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);
|
||
if (notification && !wasClicked) {
|
||
notificationMetricsService.trackDismissed(notification);
|
||
}
|
||
return prev.filter(notif => notif.id !== id);
|
||
});
|
||
}, []);
|
||
|
||
/**
|
||
* 清空所有通知
|
||
*/
|
||
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([]);
|
||
}, []);
|
||
|
||
/**
|
||
* 切换音效开关
|
||
*/
|
||
const toggleSound = useCallback(() => {
|
||
setSoundEnabled(prev => {
|
||
const newValue = !prev;
|
||
logger.info('NotificationContext', 'Sound toggled', { enabled: newValue });
|
||
return newValue;
|
||
});
|
||
}, []);
|
||
|
||
/**
|
||
* 请求浏览器通知权限
|
||
*/
|
||
const requestBrowserPermission = useCallback(async () => {
|
||
logger.info('NotificationContext', 'Requesting browser notification permission');
|
||
const permission = await browserNotificationService.requestPermission();
|
||
setBrowserPermission(permission);
|
||
|
||
// 记录已请求过权限
|
||
setHasRequestedPermission(true);
|
||
localStorage.setItem('browser_notification_requested', 'true');
|
||
|
||
// 根据权限结果显示 Toast 提示
|
||
if (permission === 'granted') {
|
||
toast({
|
||
title: '桌面通知已开启',
|
||
description: '您现在可以在后台接收重要通知',
|
||
status: 'success',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
} else if (permission === 'denied') {
|
||
toast({
|
||
title: '桌面通知已关闭',
|
||
description: '您将继续在网页内收到通知',
|
||
status: 'info',
|
||
duration: 5000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
|
||
return permission;
|
||
}, [toast]);
|
||
|
||
/**
|
||
* ⚡ 显示权限引导(通用方法)
|
||
* @param {string} guideType - 引导类型
|
||
* @param {object} options - 引导选项
|
||
*/
|
||
const showPermissionGuide = useCallback((guideType, options = {}) => {
|
||
// 检查是否应该显示引导
|
||
if (!shouldShowGuide(guideType)) {
|
||
logger.debug('NotificationContext', 'Guide already shown, skipping', { guideType });
|
||
return;
|
||
}
|
||
|
||
// 检查权限状态:只在未授权时显示引导
|
||
if (browserPermission === 'granted') {
|
||
logger.debug('NotificationContext', 'Permission already granted, skipping guide', { guideType });
|
||
return;
|
||
}
|
||
|
||
// 默认选项
|
||
const {
|
||
title = '开启桌面通知',
|
||
description = '及时接收重要事件和股票提醒',
|
||
icon = true,
|
||
duration = 10000,
|
||
} = options;
|
||
|
||
// 显示引导 Toast
|
||
const toastId = `permission-guide-${guideType}`;
|
||
if (!toast.isActive(toastId)) {
|
||
toast({
|
||
id: toastId,
|
||
duration,
|
||
render: ({ onClose }) => (
|
||
<Box
|
||
p={4}
|
||
bg="blue.500"
|
||
color="white"
|
||
borderRadius="md"
|
||
boxShadow="lg"
|
||
maxW="400px"
|
||
>
|
||
<VStack spacing={3} align="stretch">
|
||
{icon && (
|
||
<HStack spacing={2}>
|
||
<Icon as={BellIcon} boxSize={5} />
|
||
<Text fontWeight="bold" fontSize="md">
|
||
{title}
|
||
</Text>
|
||
</HStack>
|
||
)}
|
||
<Text fontSize="sm" opacity={0.9}>
|
||
{description}
|
||
</Text>
|
||
<HStack spacing={2} justify="flex-end">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
colorScheme="whiteAlpha"
|
||
onClick={() => {
|
||
onClose();
|
||
markGuideAsShown(guideType);
|
||
}}
|
||
>
|
||
稍后再说
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
colorScheme="whiteAlpha"
|
||
bg="whiteAlpha.300"
|
||
_hover={{ bg: 'whiteAlpha.400' }}
|
||
onClick={async () => {
|
||
onClose();
|
||
markGuideAsShown(guideType);
|
||
await requestBrowserPermission();
|
||
}}
|
||
>
|
||
立即开启
|
||
</Button>
|
||
</HStack>
|
||
</VStack>
|
||
</Box>
|
||
),
|
||
});
|
||
|
||
logger.info('NotificationContext', 'Permission guide shown', { guideType });
|
||
}
|
||
}, [toast, shouldShowGuide, markGuideAsShown, browserPermission, requestBrowserPermission]);
|
||
|
||
/**
|
||
* ⚡ 显示欢迎引导(登录后)
|
||
*/
|
||
const showWelcomeGuide = useCallback(() => {
|
||
showPermissionGuide(GUIDE_TYPES.WELCOME, {
|
||
title: '🎉 欢迎使用价值前沿',
|
||
description: '开启桌面通知,第一时间接收重要投资事件和股票提醒',
|
||
duration: 12000,
|
||
});
|
||
}, [showPermissionGuide]);
|
||
|
||
/**
|
||
* ⚡ 显示社区功能引导
|
||
*/
|
||
const showCommunityGuide = useCallback(() => {
|
||
showPermissionGuide(GUIDE_TYPES.COMMUNITY, {
|
||
title: '关注感兴趣的事件',
|
||
description: '开启通知后,您关注的事件有新动态时会第一时间提醒您',
|
||
duration: 10000,
|
||
});
|
||
}, [showPermissionGuide]);
|
||
|
||
/**
|
||
* ⚡ 显示首次关注引导
|
||
*/
|
||
const showFirstFollowGuide = useCallback(() => {
|
||
showPermissionGuide(GUIDE_TYPES.FIRST_FOLLOW, {
|
||
title: '关注成功',
|
||
description: '开启桌面通知,事件有更新时我们会及时提醒您',
|
||
duration: 8000,
|
||
});
|
||
}, [showPermissionGuide]);
|
||
|
||
/**
|
||
* 发送浏览器通知
|
||
*/
|
||
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;
|
||
}
|
||
|
||
const { priority, title, content, link, type } = notificationData;
|
||
|
||
// 生成唯一 tag
|
||
const tag = `${type}_${Date.now()}`;
|
||
|
||
// 判断是否需要用户交互(紧急通知不自动关闭)
|
||
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
|
||
|
||
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
|
||
title,
|
||
body: content,
|
||
tag,
|
||
requireInteraction,
|
||
link
|
||
});
|
||
|
||
// 发送浏览器通知
|
||
const notification = browserNotificationService.sendNotification({
|
||
title: title || '新通知',
|
||
body: content || '',
|
||
tag,
|
||
requireInteraction,
|
||
data: { link },
|
||
autoClose: requireInteraction ? 0 : 8000,
|
||
});
|
||
|
||
if (notification) {
|
||
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
|
||
|
||
// 设置点击处理(聚焦窗口并跳转)
|
||
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]);
|
||
|
||
/**
|
||
* 事件数据适配器 - 将后端事件格式转换为前端通知格式
|
||
* @param {object} event - 后端事件对象
|
||
* @returns {object} - 前端通知对象
|
||
*/
|
||
const adaptEventToNotification = useCallback((event) => {
|
||
// 检测数据格式:如果已经是前端格式(包含 priority),直接返回
|
||
if (event.priority || event.type === NOTIFICATION_TYPES.ANNOUNCEMENT || event.type === NOTIFICATION_TYPES.STOCK_ALERT) {
|
||
logger.debug('NotificationContext', 'Event is already in notification format', { id: event.id });
|
||
return event;
|
||
}
|
||
|
||
// 转换后端事件格式到前端通知格式
|
||
logger.debug('NotificationContext', 'Converting backend event to notification format', {
|
||
eventId: event.id,
|
||
eventType: event.event_type,
|
||
importance: event.importance
|
||
});
|
||
|
||
// 重要性映射:S/A → urgent/important, B/C → normal
|
||
let priority = PRIORITY_LEVELS.NORMAL;
|
||
if (event.importance === 'S') {
|
||
priority = PRIORITY_LEVELS.URGENT;
|
||
} else if (event.importance === 'A') {
|
||
priority = PRIORITY_LEVELS.IMPORTANT;
|
||
}
|
||
|
||
// 获取自动关闭时长
|
||
const autoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
|
||
|
||
// 构建通知对象
|
||
const notification = {
|
||
id: event.id || `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||
type: NOTIFICATION_TYPES.EVENT_ALERT, // 统一使用"事件动向"类型
|
||
priority: priority,
|
||
title: event.title || '新事件',
|
||
content: event.description || event.content || '',
|
||
publishTime: event.created_at ? new Date(event.created_at).getTime() : Date.now(),
|
||
pushTime: Date.now(),
|
||
timestamp: Date.now(),
|
||
isAIGenerated: event.is_ai_generated || false,
|
||
clickable: true,
|
||
link: `/event-detail/${event.id}`,
|
||
autoClose: autoClose,
|
||
extra: {
|
||
eventId: event.id,
|
||
eventType: event.event_type,
|
||
importance: event.importance,
|
||
status: event.status,
|
||
hotScore: event.hot_score,
|
||
viewCount: event.view_count,
|
||
relatedAvgChg: event.related_avg_chg,
|
||
relatedMaxChg: event.related_max_chg,
|
||
keywords: event.keywords || [],
|
||
},
|
||
};
|
||
|
||
logger.info('NotificationContext', 'Event converted to notification', {
|
||
eventId: event.id,
|
||
notificationId: notification.id,
|
||
priority: notification.priority,
|
||
});
|
||
|
||
return notification;
|
||
}, []);
|
||
|
||
/**
|
||
* 添加网页通知(内部方法)
|
||
*/
|
||
const addWebNotification = useCallback((newNotification) => {
|
||
// 监控埋点:追踪通知接收
|
||
notificationMetricsService.trackReceived(newNotification);
|
||
|
||
// 保存到历史记录
|
||
notificationHistoryService.saveNotification(newNotification);
|
||
|
||
// 新消息插入到数组开头,最多保留 maxHistory 条
|
||
setNotifications(prev => {
|
||
const updated = [newNotification, ...prev];
|
||
const maxNotifications = NOTIFICATION_CONFIG.maxHistory;
|
||
|
||
// 如果超过最大数量,移除最旧的(数组末尾)
|
||
if (updated.length > maxNotifications) {
|
||
const removed = updated.slice(maxNotifications);
|
||
removed.forEach(old => {
|
||
logger.info('NotificationContext', 'Auto-removing old notification', { id: old.id });
|
||
});
|
||
return updated.slice(0, maxNotifications);
|
||
}
|
||
|
||
return updated;
|
||
});
|
||
|
||
// 播放音效
|
||
playNotificationSound();
|
||
|
||
// 自动关闭
|
||
if (newNotification.autoClose && newNotification.autoClose > 0) {
|
||
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]);
|
||
|
||
/**
|
||
* 添加通知到队列
|
||
* @param {object} notification - 通知对象
|
||
*/
|
||
const addNotification = useCallback(async (notification) => {
|
||
// ========== 显示层去重检查 ==========
|
||
const notificationId = notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
|
||
// 检查当前显示队列中是否已存在该通知
|
||
const isDuplicate = notifications.some(n => n.id === notificationId);
|
||
if (isDuplicate) {
|
||
logger.debug('NotificationContext', 'Duplicate notification ignored at display level', { id: notificationId });
|
||
return notificationId; // 返回ID但不显示
|
||
}
|
||
// ========== 显示层去重检查结束 ==========
|
||
|
||
// 根据优先级获取自动关闭时长
|
||
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
|
||
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
|
||
|
||
const newNotification = {
|
||
id: notificationId, // 使用预先生成的ID
|
||
type: notification.type || 'info',
|
||
severity: notification.severity || 'info',
|
||
title: notification.title || '通知',
|
||
message: notification.message || '',
|
||
timestamp: notification.timestamp || Date.now(),
|
||
priority: priority,
|
||
autoClose: notification.autoClose !== undefined ? notification.autoClose : defaultAutoClose,
|
||
...notification,
|
||
};
|
||
|
||
logger.info('NotificationContext', 'Adding notification', newNotification);
|
||
|
||
// ========== 增强权限请求策略 ==========
|
||
// 只要收到通知,就检查并提示用户授权
|
||
|
||
// 如果权限是default(未授权),自动请求
|
||
if (browserPermission === 'default' && !hasRequestedPermission) {
|
||
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
|
||
await requestBrowserPermission();
|
||
}
|
||
// 如果权限是denied(已拒绝),提供设置指引
|
||
else if (browserPermission === 'denied') {
|
||
const toastId = 'browser-permission-denied-guide';
|
||
if (!toast.isActive(toastId)) {
|
||
toast({
|
||
id: toastId,
|
||
duration: 12000,
|
||
isClosable: true,
|
||
position: 'top',
|
||
render: ({ onClose }) => (
|
||
<Box
|
||
p={4}
|
||
bg="orange.500"
|
||
color="white"
|
||
borderRadius="md"
|
||
boxShadow="lg"
|
||
maxW="400px"
|
||
>
|
||
<VStack spacing={3} align="stretch">
|
||
<HStack spacing={2}>
|
||
<Icon as={BellIcon} boxSize={5} />
|
||
<Text fontWeight="bold" fontSize="md">
|
||
浏览器通知已被拒绝
|
||
</Text>
|
||
</HStack>
|
||
<Text fontSize="sm" opacity={0.9}>
|
||
{newNotification.title}
|
||
</Text>
|
||
<Text fontSize="xs" opacity={0.8}>
|
||
💡 如需接收桌面通知,请在浏览器设置中允许通知权限
|
||
</Text>
|
||
<VStack spacing={1} align="start" fontSize="xs" opacity={0.7}>
|
||
<Text>Chrome: 地址栏左侧 🔒 → 网站设置 → 通知</Text>
|
||
<Text>Safari: 偏好设置 → 网站 → 通知</Text>
|
||
<Text>Edge: 地址栏右侧 ⋯ → 网站权限 → 通知</Text>
|
||
</VStack>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
colorScheme="whiteAlpha"
|
||
onClick={onClose}
|
||
alignSelf="flex-end"
|
||
>
|
||
知道了
|
||
</Button>
|
||
</VStack>
|
||
</Box>
|
||
),
|
||
});
|
||
}
|
||
}
|
||
|
||
const isPageHidden = document.hidden; // 页面是否在后台
|
||
|
||
// ========== 通知分发策略(区分前后台) ==========
|
||
// 策略: 根据页面可见性智能分发通知
|
||
// - 页面在后台: 发送浏览器通知(系统级提醒)
|
||
// - 页面在前台: 发送网页通知(页面内 Toast)
|
||
// 注: 不再区分优先级,统一使用前后台策略
|
||
if (isPageHidden) {
|
||
// 页面在后台:发送浏览器通知
|
||
logger.info('NotificationContext', 'Page hidden: sending browser notification');
|
||
sendBrowserNotification(newNotification);
|
||
} else {
|
||
// 页面在前台:发送网页通知
|
||
logger.info('NotificationContext', 'Page visible: sending web notification');
|
||
addWebNotification(newNotification);
|
||
}
|
||
|
||
return newNotification.id;
|
||
}, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
|
||
|
||
/**
|
||
* ✅ 方案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] 🚀 初始化 Socket 连接(方案2:只注册一次)', 'color: #673AB7; font-weight: bold;');
|
||
|
||
// ========== 监听连接成功(首次连接 + 重连) ==========
|
||
socket.on('connect', () => {
|
||
setIsConnected(true);
|
||
setReconnectAttempt(0);
|
||
|
||
// 判断是首次连接还是重连
|
||
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', 'Socket reconnected');
|
||
|
||
// 清除之前的定时器
|
||
if (reconnectedTimerRef.current) {
|
||
clearTimeout(reconnectedTimerRef.current);
|
||
}
|
||
|
||
// 2秒后自动变回 CONNECTED
|
||
reconnectedTimerRef.current = setTimeout(() => {
|
||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
|
||
}, 2000);
|
||
}
|
||
|
||
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
|
||
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);
|
||
|
||
const attempts = socket.getReconnectAttempts?.() || 0;
|
||
setReconnectAttempt(attempts);
|
||
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,
|
||
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 || '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 });
|
||
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
|
||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||
return;
|
||
}
|
||
|
||
processedEventIds.current.add(eventId);
|
||
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
|
||
|
||
// 限制 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,
|
||
});
|
||
}
|
||
// ========== Socket层去重检查结束 ==========
|
||
|
||
// ✅ 使用 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);
|
||
console.log('[NotificationContext] 📢 收到系统通知:', data);
|
||
|
||
if (addNotificationRef.current) {
|
||
addNotificationRef.current(data);
|
||
} else {
|
||
console.error('[NotificationContext] ❌ addNotificationRef 未初始化');
|
||
}
|
||
});
|
||
|
||
console.log('%c[NotificationContext] ✅ 所有监听器已注册(只注册一次)', 'color: #4CAF50; font-weight: bold;');
|
||
|
||
// ========== 获取最大重连次数 ==========
|
||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||
setMaxReconnectAttempts(maxAttempts);
|
||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||
|
||
// ========== 启动连接 ==========
|
||
console.log('%c[NotificationContext] 🔌 调用 socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||
socket.connect();
|
||
|
||
// ========== 清理函数(组件卸载时) ==========
|
||
return () => {
|
||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
|
||
|
||
// 清理 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;');
|
||
};
|
||
}, []); // ⚠️ 空依赖数组,确保只执行一次
|
||
|
||
// ==================== 智能自动重试 ====================
|
||
|
||
/**
|
||
* 标签页聚焦时自动重试
|
||
*/
|
||
useEffect(() => {
|
||
const handleVisibilityChange = () => {
|
||
if (document.visibilityState === 'visible' && !isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
|
||
logger.info('NotificationContext', 'Tab refocused, attempting auto-reconnect');
|
||
socket.reconnect?.();
|
||
}
|
||
};
|
||
|
||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||
|
||
return () => {
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||
};
|
||
}, [isConnected, connectionStatus]);
|
||
|
||
/**
|
||
* 网络恢复时自动重试
|
||
*/
|
||
useEffect(() => {
|
||
const handleOnline = () => {
|
||
if (!isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
|
||
logger.info('NotificationContext', 'Network restored, attempting auto-reconnect');
|
||
toast({
|
||
title: '网络已恢复',
|
||
description: '正在重新连接...',
|
||
status: 'info',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
|
||
socket.reconnect?.();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('online', handleOnline);
|
||
|
||
return () => {
|
||
window.removeEventListener('online', handleOnline);
|
||
};
|
||
}, [isConnected, connectionStatus, toast]);
|
||
|
||
/**
|
||
* 追踪通知点击
|
||
* @param {string} id - 通知ID
|
||
*/
|
||
const trackNotificationClick = useCallback((id) => {
|
||
const notification = notifications.find(n => n.id === id);
|
||
if (notification) {
|
||
logger.info('NotificationContext', 'Notification clicked', { id });
|
||
// 监控埋点:追踪点击
|
||
notificationMetricsService.trackClicked(notification);
|
||
// 标记历史记录为已点击
|
||
notificationHistoryService.markAsClicked(id);
|
||
}
|
||
}, [notifications]);
|
||
|
||
/**
|
||
* 手动重试连接
|
||
*/
|
||
const retryConnection = useCallback(() => {
|
||
logger.info('NotificationContext', 'Manual reconnection triggered');
|
||
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
|
||
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,
|
||
soundEnabled,
|
||
browserPermission,
|
||
connectionStatus,
|
||
reconnectAttempt,
|
||
maxReconnectAttempts,
|
||
addNotification,
|
||
removeNotification,
|
||
clearAllNotifications,
|
||
toggleSound,
|
||
requestBrowserPermission,
|
||
trackNotificationClick,
|
||
retryConnection,
|
||
// ⚡ 新增:权限引导方法
|
||
showWelcomeGuide,
|
||
showCommunityGuide,
|
||
showFirstFollowGuide,
|
||
};
|
||
|
||
return (
|
||
<NotificationContext.Provider value={value}>
|
||
{children}
|
||
</NotificationContext.Provider>
|
||
);
|
||
};
|
||
|
||
// 导出连接状态枚举供外部使用
|
||
export { CONNECTION_STATUS };
|
||
|
||
export default NotificationContext;
|