feat: 通知调试能力

This commit is contained in:
zdl
2025-11-10 20:05:53 +08:00
parent 8e5623d723
commit 643c3db03e
18 changed files with 1218 additions and 1424 deletions

View File

@@ -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,