// 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 }) => ( {icon && ( {title} )} {description} ), }); 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 }) => ( 浏览器通知已被拒绝 {newNotification.title} 💡 如需接收桌面通知,请在浏览器设置中允许通知权限 Chrome: 地址栏左侧 🔒 → 网站设置 → 通知 Safari: 偏好设置 → 网站 → 通知 Edge: 地址栏右侧 ⋯ → 网站权限 → 通知 ), }); } } 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 ( {children} ); }; // 导出连接状态枚举供外部使用 export { CONNECTION_STATUS }; export default NotificationContext;