// src/contexts/NotificationContext.js /** * 通知上下文 - 管理实时消息推送和通知显示 */ import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; import { useToast, Box, HStack, Text, Button, CloseButton } from '@chakra-ui/react'; 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'; // 连接状态枚举 const CONNECTION_STATUS = { CONNECTED: 'connected', DISCONNECTED: 'disconnected', RECONNECTING: 'reconnecting', FAILED: 'failed', }; // 创建通知上下文 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 audioRef = useRef(null); // 初始化音频 useEffect(() => { try { audioRef.current = new Audio(notificationSound); audioRef.current.volume = 0.5; } catch (error) { logger.error('NotificationContext', 'Audio initialization failed', error); } }, []); /** * 播放通知音效 */ 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 }); // 监控埋点:追踪关闭(非点击的情况) 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'); 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]); /** * 发送浏览器通知 */ const sendBrowserNotification = useCallback((notificationData) => { if (browserPermission !== 'granted') { logger.warn('NotificationContext', 'Browser permission not granted'); return; } const { priority, title, content, link, type } = notificationData; // 生成唯一 tag const tag = `${type}_${Date.now()}`; // 判断是否需要用户交互(紧急通知不自动关闭) const requireInteraction = priority === PRIORITY_LEVELS.URGENT; // 发送浏览器通知 const notification = browserNotificationService.sendNotification({ title: title || '新通知', body: content || '', tag, requireInteraction, data: { link }, autoClose: requireInteraction ? 0 : 8000, }); // 设置点击处理(聚焦窗口并跳转) if (notification && link) { notification.onclick = () => { window.focus(); // 使用 window.location 跳转(不需要 React Router) window.location.hash = link; notification.close(); }; } logger.info('NotificationContext', 'Browser notification sent', { title, tag }); }, [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) { setTimeout(() => { removeNotification(newNotification.id); }, newNotification.autoClose); } }, [playNotificationSound, removeNotification]); /** * 添加通知到队列 * @param {object} notification - 通知对象 */ const addNotification = useCallback(async (notification) => { // 根据优先级获取自动关闭时长 const priority = notification.priority || PRIORITY_LEVELS.NORMAL; const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority]; const newNotification = { id: notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, 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); // ========== 智能权限请求策略 ========== // 首次收到重要/紧急通知时,自动请求桌面通知权限 if (priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT) { if (browserPermission === 'default' && !hasRequestedPermission) { logger.info('NotificationContext', 'First important notification, requesting browser permission'); await requestBrowserPermission(); } // 如果权限被拒绝,提示用户可以开启 else if (browserPermission === 'denied' && hasRequestedPermission) { // 显示带"开启"按钮的 Toast(仅重要/紧急通知) const toastId = 'enable-notification-toast'; if (!toast.isActive(toastId)) { toast({ id: toastId, title: newNotification.title, description: '💡 开启桌面通知以便后台接收', status: 'warning', duration: 10000, isClosable: true, position: 'top', render: ({ onClose }) => ( {newNotification.title} 💡 开启桌面通知以便后台接收 ), }); } } } const isPageHidden = document.hidden; // 页面是否在后台 // ========== 智能分发策略 ========== // 策略 1: 紧急通知 - 双重保障(浏览器 + 网页) if (priority === PRIORITY_LEVELS.URGENT) { logger.info('NotificationContext', 'Urgent notification: sending browser + web'); // 总是发送浏览器通知 sendBrowserNotification(newNotification); // 如果在前台,也显示网页通知 if (!isPageHidden) { addWebNotification(newNotification); } } // 策略 2: 重要通知 - 智能切换(后台=浏览器,前台=网页) else if (priority === PRIORITY_LEVELS.IMPORTANT) { if (isPageHidden) { logger.info('NotificationContext', 'Important notification (background): sending browser'); sendBrowserNotification(newNotification); } else { logger.info('NotificationContext', 'Important notification (foreground): sending web'); addWebNotification(newNotification); } } // 策略 3: 普通通知 - 仅网页通知 else { logger.info('NotificationContext', 'Normal notification: sending web only'); addWebNotification(newNotification); } return newNotification.id; }, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]); // 连接到 Socket 服务 useEffect(() => { logger.info('NotificationContext', 'Initializing socket connection...'); // 连接 socket socket.connect(); // 监听连接状态 socket.on('connect', () => { setIsConnected(true); setConnectionStatus(CONNECTION_STATUS.CONNECTED); setReconnectAttempt(0); logger.info('NotificationContext', 'Socket connected'); // 显示重连成功提示(如果之前断开过) if (connectionStatus !== CONNECTION_STATUS.CONNECTED) { toast({ title: '已重新连接', status: 'success', duration: 2000, isClosable: true, }); } // 如果使用 mock,可以启动定期推送 if (SOCKET_TYPE === 'MOCK') { // 启动模拟推送:使用配置的间隔和数量 const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush; socket.startMockPush(interval, maxBatch); logger.info('NotificationContext', 'Mock push started', { interval, maxBatch }); } }); 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 Socket 有) if (SOCKET_TYPE === 'REAL') { const attempts = socket.getReconnectAttempts?.() || 0; setReconnectAttempt(attempts); } }); // 监听重连失败 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) => { logger.info('NotificationContext', 'Received new event', data); // 使用适配器转换事件格式 const notification = adaptEventToNotification(data); addNotification(notification); }); // 保留系统通知监听(兼容性) socket.on('system_notification', (data) => { logger.info('NotificationContext', 'Received system notification', data); addNotification(data); }); // 清理函数 return () => { logger.info('NotificationContext', 'Cleaning up socket connection'); // 如果是 mock service,停止推送 if (SOCKET_TYPE === 'MOCK') { socket.stopMockPush(); } socket.off('connect'); socket.off('disconnect'); socket.off('connect_error'); socket.off('reconnect_failed'); socket.off('new_event'); socket.off('system_notification'); socket.disconnect(); }; }, [adaptEventToNotification, connectionStatus, toast]); // eslint-disable-line react-hooks/exhaustive-deps // ==================== 智能自动重试 ==================== /** * 标签页聚焦时自动重试 */ 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(); } }, []); const value = { notifications, isConnected, soundEnabled, browserPermission, connectionStatus, reconnectAttempt, addNotification, removeNotification, clearAllNotifications, toggleSound, requestBrowserPermission, trackNotificationClick, retryConnection, }; return ( {children} ); }; // 导出连接状态枚举供外部使用 export { CONNECTION_STATUS }; export default NotificationContext;