// 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); // 标记是否首次连接 // ⚡ 事件更新回调列表(用于在收到 new_event 时通知其他组件刷新数据) const eventUpdateCallbacks = useRef(new Set()); // ⚡ 使用权限引导管理 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; }); }, []); /** * 注册事件更新回调(用于在收到新事件时通知其他组件刷新) * @param {Function} callback - 回调函数,接收 eventData 参数 * @returns {Function} 取消注册函数 */ const registerEventUpdateCallback = useCallback((callback) => { eventUpdateCallbacks.current.add(callback); logger.info('NotificationContext', 'Event update callback registered', { totalCallbacks: eventUpdateCallbacks.current.size }); // 返回取消注册函数 return () => { eventUpdateCallbacks.current.delete(callback); logger.info('NotificationContext', 'Event update callback unregistered', { totalCallbacks: eventUpdateCallbacks.current.size }); }; }, []); /** * 取消注册事件更新回调(已废弃,建议使用 registerEventUpdateCallback 返回的函数) * @param {Function} callback - 要取消的回调函数 */ const unregisterEventUpdateCallback = useCallback((callback) => { eventUpdateCallbacks.current.delete(callback); logger.info('NotificationContext', 'Event update callback unregistered (manual)', { totalCallbacks: eventUpdateCallbacks.current.size }); }, []); /** * 请求浏览器通知权限 */ 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) => { logger.debug('NotificationContext', 'sendBrowserNotification 被调用', { notificationData, browserPermission }); if (browserPermission !== 'granted') { logger.warn('NotificationContext', '浏览器权限未授予,无法发送通知'); return; } const { priority, title, content, link, type } = notificationData; // 生成唯一 tag const tag = `${type}_${Date.now()}`; // 判断是否需要用户交互(紧急通知不自动关闭) const requireInteraction = priority === PRIORITY_LEVELS.URGENT; logger.debug('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) { logger.info('NotificationContext', '通知对象创建成功', { notification }); // 设置点击处理(聚焦窗口并跳转) if (link) { notification.onclick = () => { logger.info('NotificationContext', '通知被点击,跳转到', { link }); window.focus(); // 使用 window.location 跳转(不需要 React Router) window.location.hash = link; notification.close(); }; } logger.info('NotificationContext', 'Browser notification sent', { title, tag }); } else { logger.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; logger.debug('NotificationContext', '已更新 addNotificationRef'); }, [addNotification]); useEffect(() => { adaptEventToNotificationRef.current = adaptEventToNotification; logger.debug('NotificationContext', '已更新 adaptEventToNotificationRef'); }, [adaptEventToNotification]); // ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ========== useEffect(() => { logger.info('NotificationContext', '初始化 Socket 连接(方案2:只注册一次)'); // ========== 监听连接成功(首次连接 + 重连) ========== socket.on('connect', () => { setIsConnected(true); setReconnectAttempt(0); // 判断是首次连接还是重连 if (isFirstConnect.current) { logger.info('NotificationContext', '首次连接成功', { socketId: socket.getSocketId?.() }); setConnectionStatus(CONNECTION_STATUS.CONNECTED); isFirstConnect.current = false; } else { logger.info('NotificationContext', '重连成功'); setConnectionStatus(CONNECTION_STATUS.RECONNECTED); // 清除之前的定时器 if (reconnectedTimerRef.current) { clearTimeout(reconnectedTimerRef.current); } // 2秒后自动变回 CONNECTED reconnectedTimerRef.current = setTimeout(() => { setConnectionStatus(CONNECTION_STATUS.CONNECTED); logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status'); }, 2000); } // ⚡ 重连后只需重新订阅,不需要重新注册监听器 logger.info('NotificationContext', '重新订阅事件推送'); if (socket.subscribeToEvents) { socket.subscribeToEvents({ eventType: 'all', importance: 'all', onSubscribed: (data) => { logger.info('NotificationContext', '订阅成功', data); }, }); } else { logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用'); } }); // ========== 监听断开连接 ========== socket.on('disconnect', (reason) => { setIsConnected(false); setConnectionStatus(CONNECTION_STATUS.DISCONNECTED); logger.warn('NotificationContext', 'Socket 已断开', { 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', `重连中... (第 ${attempts} 次尝试)`); }); // ========== 监听重连失败 ========== socket.on('reconnect_failed', () => { logger.error('NotificationContext', '重连失败'); setConnectionStatus(CONNECTION_STATUS.FAILED); toast({ title: '连接失败', description: '无法连接到服务器,请检查网络连接', status: 'error', duration: null, isClosable: true, }); }); // ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ========== socket.on('new_event', (data) => { logger.info('NotificationContext', '收到 new_event 事件', { id: data?.id, title: data?.title, eventType: data?.event_type || data?.type, importance: data?.importance }); logger.debug('NotificationContext', '原始事件数据', data); // ⚠️ 防御性检查:确保 ref 已初始化 if (!addNotificationRef.current || !adaptEventToNotificationRef.current) { logger.error('NotificationContext', 'Ref 未初始化,跳过处理', { 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.warn('NotificationContext', '重复事件已忽略', { eventId }); return; } processedEventIds.current.add(eventId); logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId }); // 限制 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 访问最新的适配器函数(避免闭包陷阱) logger.debug('NotificationContext', '正在转换事件格式'); const notification = adaptEventToNotificationRef.current(data); logger.debug('NotificationContext', '转换后的通知对象', notification); // ✅ 使用 ref.current 访问最新的 addNotification 函数 logger.debug('NotificationContext', '准备添加通知到队列'); addNotificationRef.current(notification); logger.info('NotificationContext', '通知已添加到队列'); // ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据) if (eventUpdateCallbacks.current.size > 0) { logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`); eventUpdateCallbacks.current.forEach(callback => { try { callback(data); } catch (error) { logger.error('NotificationContext', '事件更新回调执行失败', error); } }); logger.debug('NotificationContext', '所有事件更新回调已触发'); } }); // ========== 监听系统通知(兼容性) ========== socket.on('system_notification', (data) => { logger.info('NotificationContext', '收到系统通知', data); if (addNotificationRef.current) { addNotificationRef.current(data); } else { logger.error('NotificationContext', 'addNotificationRef 未初始化'); } }); logger.info('NotificationContext', '所有监听器已注册(只注册一次)'); // ========== 获取最大重连次数 ========== const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity; setMaxReconnectAttempts(maxAttempts); logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts }); // ========== 启动连接 ========== logger.info('NotificationContext', '调用 socket.connect()'); socket.connect(); // ========== 清理函数(组件卸载时) ========== return () => { logger.info('NotificationContext', '清理 Socket 连接'); // 清理 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(); logger.info('NotificationContext', '清理完成'); }; }, []); // ⚠️ 空依赖数组,确保只执行一次 // ==================== 智能自动重试 ==================== /** * 标签页聚焦时自动重试 */ 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]); const value = { notifications, isConnected, soundEnabled, browserPermission, connectionStatus, reconnectAttempt, maxReconnectAttempts, addNotification, removeNotification, clearAllNotifications, toggleSound, requestBrowserPermission, trackNotificationClick, retryConnection, // ⚡ 新增:权限引导方法 showWelcomeGuide, showCommunityGuide, showFirstFollowGuide, // ⚡ 新增:事件更新回调注册方法 registerEventUpdateCallback, unregisterEventUpdateCallback, }; return ( {children} ); }; // 导出连接状态枚举供外部使用 export { CONNECTION_STATUS }; export default NotificationContext;