// 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;