feat: 通知调试能力
This commit is contained in:
@@ -2,20 +2,15 @@
|
||||
/**
|
||||
* 通知上下文 - 管理实时消息推送和通知显示
|
||||
*
|
||||
* 环境说明:
|
||||
* - 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 模式连接生产环境
|
||||
* 使用真实 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, { SOCKET_TYPE } from '../services/socket';
|
||||
import socket from '../services/socket';
|
||||
import notificationSound from '../assets/sounds/notification.wav';
|
||||
import { browserNotificationService } from '../services/browserNotificationService';
|
||||
import { notificationMetricsService } from '../services/notificationMetricsService';
|
||||
@@ -62,6 +57,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
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();
|
||||
@@ -71,9 +67,20 @@ export const NotificationProvider = ({ children }) => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -104,6 +111,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
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);
|
||||
@@ -119,6 +133,14 @@ export const NotificationProvider = ({ children }) => {
|
||||
*/
|
||||
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([]);
|
||||
}, []);
|
||||
|
||||
@@ -446,9 +468,16 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// 自动关闭
|
||||
if (newNotification.autoClose && newNotification.autoClose > 0) {
|
||||
setTimeout(() => {
|
||||
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]);
|
||||
|
||||
@@ -548,34 +577,11 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
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);
|
||||
// }
|
||||
|
||||
// ========== 新分发策略(仅区分前后台) ==========
|
||||
// ========== 通知分发策略(区分前后台) ==========
|
||||
// 策略: 根据页面可见性智能分发通知
|
||||
// - 页面在后台: 发送浏览器通知(系统级提醒)
|
||||
// - 页面在前台: 发送网页通知(页面内 Toast)
|
||||
// 注: 不再区分优先级,统一使用前后台策略
|
||||
if (isPageHidden) {
|
||||
// 页面在后台:发送浏览器通知
|
||||
logger.info('NotificationContext', 'Page hidden: sending browser notification');
|
||||
@@ -592,7 +598,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 连接到 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] Initializing socket connection', 'color: #673AB7; font-weight: bold;');
|
||||
|
||||
// ✅ 第一步: 注册所有事件监听器
|
||||
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
|
||||
@@ -624,30 +630,22 @@ export const NotificationProvider = ({ children }) => {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
}
|
||||
|
||||
// 如果使用 mock,可以启动定期推送
|
||||
if (SOCKET_TYPE === 'MOCK') {
|
||||
// 启动模拟推送:使用配置的间隔和数量
|
||||
const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
|
||||
socket.startMockPush(interval, maxBatch);
|
||||
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
|
||||
} else {
|
||||
// ✅ 真实模式下,订阅事件推送
|
||||
console.log('%c[NotificationContext] 🔔 订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
// 订阅事件推送
|
||||
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.warn('[NotificationContext] ⚠️ socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
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 方法不可用');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -662,10 +660,10 @@ export const NotificationProvider = ({ children }) => {
|
||||
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 });
|
||||
logger.info('NotificationContext', 'Reconnection attempt', { attempts });
|
||||
});
|
||||
|
||||
// 监听重连失败
|
||||
@@ -696,7 +694,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
logger.info('NotificationContext', 'Received new event', data);
|
||||
|
||||
// ========== Socket层去重检查 ==========
|
||||
const eventId = data.id || `${data.type}_${data.publishTime}`;
|
||||
// 生成更健壮的事件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 });
|
||||
@@ -752,11 +761,19 @@ export const NotificationProvider = ({ children }) => {
|
||||
return () => {
|
||||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||||
|
||||
// 如果是 mock service,停止推送
|
||||
if (SOCKET_TYPE === 'MOCK') {
|
||||
socket.stopMockPush();
|
||||
// 清理 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');
|
||||
@@ -776,11 +793,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
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();
|
||||
}
|
||||
socket.reconnect?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -806,11 +819,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
if (SOCKET_TYPE === 'REAL') {
|
||||
socket.reconnect?.();
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
socket.reconnect?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -842,14 +851,51 @@ export const NotificationProvider = ({ children }) => {
|
||||
const retryConnection = useCallback(() => {
|
||||
logger.info('NotificationContext', 'Manual reconnection triggered');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
|
||||
|
||||
if (SOCKET_TYPE === 'REAL') {
|
||||
socket.reconnect?.();
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user