feat: sockt 弹窗功能添加
This commit is contained in:
@@ -4,11 +4,22 @@
|
||||
*/
|
||||
|
||||
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 { PRIORITY_LEVELS, NOTIFICATION_CONFIG } from '../constants/notificationTypes';
|
||||
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();
|
||||
@@ -24,10 +35,17 @@ export const useNotification = () => {
|
||||
|
||||
// 通知提供者组件
|
||||
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);
|
||||
|
||||
// 初始化音频
|
||||
@@ -63,10 +81,19 @@ export const NotificationProvider = ({ children }) => {
|
||||
/**
|
||||
* 移除通知
|
||||
* @param {string} id - 通知ID
|
||||
* @param {boolean} wasClicked - 是否是因为点击而关闭
|
||||
*/
|
||||
const removeNotification = useCallback((id) => {
|
||||
logger.info('NotificationContext', 'Removing notification', { id });
|
||||
setNotifications(prev => prev.filter(notif => notif.id !== id));
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -95,8 +122,32 @@ export const NotificationProvider = ({ children }) => {
|
||||
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]);
|
||||
|
||||
/**
|
||||
* 发送浏览器通知
|
||||
@@ -138,10 +189,82 @@ export const NotificationProvider = ({ children }) => {
|
||||
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];
|
||||
@@ -174,7 +297,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
* 添加通知到队列
|
||||
* @param {object} notification - 通知对象
|
||||
*/
|
||||
const addNotification = useCallback((notification) => {
|
||||
const addNotification = useCallback(async (notification) => {
|
||||
// 根据优先级获取自动关闭时长
|
||||
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
|
||||
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
|
||||
@@ -193,6 +316,62 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
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 }) => (
|
||||
<Box
|
||||
p={4}
|
||||
bg="orange.500"
|
||||
color="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
>
|
||||
<HStack spacing={3} align="start">
|
||||
<Box flex={1}>
|
||||
<Text fontWeight="bold" mb={1}>
|
||||
{newNotification.title}
|
||||
</Text>
|
||||
<Text fontSize="sm" opacity={0.9}>
|
||||
💡 开启桌面通知以便后台接收
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={() => {
|
||||
requestBrowserPermission();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
开启
|
||||
</Button>
|
||||
<CloseButton onClick={onClose} />
|
||||
</HStack>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isPageHidden = document.hidden; // 页面是否在后台
|
||||
|
||||
// ========== 智能分发策略 ==========
|
||||
@@ -224,7 +403,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
return newNotification.id;
|
||||
}, [sendBrowserNotification, addWebNotification]);
|
||||
}, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
|
||||
|
||||
// 连接到 Socket 服务
|
||||
useEffect(() => {
|
||||
@@ -236,8 +415,20 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 监听连接状态
|
||||
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') {
|
||||
// 启动模拟推送:使用配置的间隔和数量
|
||||
@@ -247,18 +438,48 @@ export const NotificationProvider = ({ children }) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
socket.on('disconnect', (reason) => {
|
||||
setIsConnected(false);
|
||||
logger.warn('NotificationContext', 'Socket disconnected');
|
||||
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
|
||||
logger.warn('NotificationContext', 'Socket disconnected', { reason });
|
||||
});
|
||||
|
||||
// 监听交易通知
|
||||
socket.on('trade_notification', (data) => {
|
||||
logger.info('NotificationContext', 'Received trade notification', data);
|
||||
addNotification(data);
|
||||
// 监听连接错误
|
||||
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);
|
||||
@@ -275,22 +496,111 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
socket.off('connect');
|
||||
socket.off('disconnect');
|
||||
socket.off('trade_notification');
|
||||
socket.off('connect_error');
|
||||
socket.off('reconnect_failed');
|
||||
socket.off('new_event');
|
||||
socket.off('system_notification');
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [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 (
|
||||
@@ -300,4 +610,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 导出连接状态枚举供外部使用
|
||||
export { CONNECTION_STATUS };
|
||||
|
||||
export default NotificationContext;
|
||||
|
||||
Reference in New Issue
Block a user