// src/contexts/NotificationContext.js /** * 通知上下文 - 管理实时消息推送和通知显示 * * 环境说明: * - 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 模式连接生产环境 */ 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 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()); // 跟踪所有通知的自动关闭定时器 // ⚡ 使用权限引导管理 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]); // 连接到 Socket 服务 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] 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) { setConnectionStatus(CONNECTION_STATUS.RECONNECTED); logger.info('NotificationContext', 'Reconnected, will auto-dismiss in 2s'); // 清除之前的定时器 if (reconnectedTimerRef.current) { clearTimeout(reconnectedTimerRef.current); } // 2秒后自动变回 CONNECTED reconnectedTimerRef.current = setTimeout(() => { setConnectionStatus(CONNECTION_STATUS.CONNECTED); logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status'); }, 2000); } else { setConnectionStatus(CONNECTION_STATUS.CONNECTED); } // 订阅事件推送 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); }, // ⚠️ 不需要 onNewEvent 回调,因为 NotificationContext 已经通过 socket.on('new_event') 监听 }); } else { console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用'); } }); socket.on('disconnect', (reason) => { setIsConnected(false); setConnectionStatus(CONNECTION_STATUS.DISCONNECTED); logger.warn('NotificationContext', 'Socket disconnected', { 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 }); }); // 监听重连失败 socket.on('reconnect_failed', () => { logger.error('NotificationContext', 'Socket reconnect_failed'); setConnectionStatus(CONNECTION_STATUS.FAILED); toast({ title: '连接失败', description: '无法连接到服务器,请检查网络连接', status: 'error', duration: null, // 不自动关闭 isClosable: true, }); }); // 监听新事件推送(统一事件名) 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); // ========== Socket层去重检查 ========== // 生成更健壮的事件ID const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // 如果缺少原始ID,记录警告 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; // 重复事件,直接忽略 } // 记录已处理的事件ID 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层去重检查结束 ========== // 使用适配器转换事件格式 console.log('[NotificationContext] 正在转换事件格式...'); const notification = adaptEventToNotification(data); console.log('[NotificationContext] 转换后的通知对象:', notification); console.log('[NotificationContext] 准备添加通知到队列...'); addNotification(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('%c[NotificationContext] ✅ All event listeners registered', '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;'); socket.connect(); console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;'); // 清理函数 return () => { logger.info('NotificationContext', 'Cleaning up socket connection'); // 清理 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(); }; }, []); // ✅ 空依赖数组,确保只执行一次,避免 React 严格模式重复执行 // ==================== 智能自动重试 ==================== /** * 标签页聚焦时自动重试 */ useEffect(() => { 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(); } } }; 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, }); if (SOCKET_TYPE === 'REAL') { socket.reconnect?.(); } else { socket.connect(); } } }; 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); if (SOCKET_TYPE === 'REAL') { socket.reconnect?.(); } else { socket.connect(); } }, []); /** * 同步浏览器通知权限状态 * 场景: * 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]); 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;