feat: sockt 弹窗功能添加
This commit is contained in:
29
src/App.js
29
src/App.js
@@ -43,22 +43,49 @@ const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
||||
// Contexts
|
||||
import { AuthProvider } from "contexts/AuthContext";
|
||||
import { AuthModalProvider } from "contexts/AuthModalContext";
|
||||
import { NotificationProvider } from "contexts/NotificationContext";
|
||||
import { NotificationProvider, useNotification } from "contexts/NotificationContext";
|
||||
|
||||
// Components
|
||||
import ProtectedRoute from "components/ProtectedRoute";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import AuthModalManager from "components/Auth/AuthModalManager";
|
||||
import NotificationContainer from "components/NotificationContainer";
|
||||
import ConnectionStatusBar from "components/ConnectionStatusBar";
|
||||
import NotificationTestTool from "components/NotificationTestTool";
|
||||
import ScrollToTop from "components/ScrollToTop";
|
||||
import { logger } from "utils/logger";
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
* 需要在 NotificationProvider 内部使用,所以单独提取
|
||||
*/
|
||||
function ConnectionStatusBarWrapper() {
|
||||
const { connectionStatus, reconnectAttempt, retryConnection } = useNotification();
|
||||
|
||||
const handleClose = () => {
|
||||
// 关闭状态条(可选,当前不实现)
|
||||
// 用户可以通过刷新页面来重新显示
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionStatusBar
|
||||
status={connectionStatus}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
maxReconnectAttempts={5}
|
||||
onRetry={retryConnection}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||
{/* Socket 连接状态条 */}
|
||||
<ConnectionStatusBarWrapper />
|
||||
|
||||
{/* 路由切换时自动滚动到顶部 */}
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
|
||||
132
src/components/ConnectionStatusBar/index.js
Normal file
132
src/components/ConnectionStatusBar/index.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// src/components/ConnectionStatusBar/index.js
|
||||
/**
|
||||
* Socket 连接状态栏组件
|
||||
* 显示 Socket 连接状态并提供重试功能
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Button,
|
||||
CloseButton,
|
||||
Box,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
Slide,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdRefresh } from 'react-icons/md';
|
||||
|
||||
/**
|
||||
* 连接状态枚举
|
||||
*/
|
||||
export const CONNECTION_STATUS = {
|
||||
CONNECTED: 'connected', // 已连接
|
||||
DISCONNECTED: 'disconnected', // 已断开
|
||||
RECONNECTING: 'reconnecting', // 重连中
|
||||
FAILED: 'failed', // 连接失败
|
||||
};
|
||||
|
||||
/**
|
||||
* 连接状态栏组件
|
||||
*/
|
||||
const ConnectionStatusBar = ({
|
||||
status = CONNECTION_STATUS.CONNECTED,
|
||||
reconnectAttempt = 0,
|
||||
maxReconnectAttempts = 5,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
// 仅在非正常状态时显示
|
||||
const shouldShow = status !== CONNECTION_STATUS.CONNECTED;
|
||||
|
||||
// 状态配置
|
||||
const statusConfig = {
|
||||
[CONNECTION_STATUS.DISCONNECTED]: {
|
||||
status: 'warning',
|
||||
title: '连接已断开',
|
||||
description: '正在尝试重新连接...',
|
||||
},
|
||||
[CONNECTION_STATUS.RECONNECTING]: {
|
||||
status: 'warning',
|
||||
title: '正在重新连接',
|
||||
description: `尝试重连中 (第 ${reconnectAttempt}/${maxReconnectAttempts} 次)`,
|
||||
},
|
||||
[CONNECTION_STATUS.FAILED]: {
|
||||
status: 'error',
|
||||
title: '连接失败',
|
||||
description: '无法连接到服务器,请检查网络连接',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig[CONNECTION_STATUS.DISCONNECTED];
|
||||
|
||||
// 颜色配置
|
||||
const bg = useColorModeValue(
|
||||
{
|
||||
warning: 'orange.50',
|
||||
error: 'red.50',
|
||||
}[config.status],
|
||||
{
|
||||
warning: 'orange.900',
|
||||
error: 'red.900',
|
||||
}[config.status]
|
||||
);
|
||||
|
||||
return (
|
||||
<Slide
|
||||
direction="top"
|
||||
in={shouldShow}
|
||||
style={{ zIndex: 10000 }}
|
||||
>
|
||||
<Alert
|
||||
status={config.status}
|
||||
variant="subtle"
|
||||
bg={bg}
|
||||
borderBottom="1px solid"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
py={3}
|
||||
px={{ base: 4, md: 6 }}
|
||||
>
|
||||
<AlertIcon />
|
||||
<Box flex="1">
|
||||
<HStack spacing={2} align="center" flexWrap="wrap">
|
||||
<AlertTitle fontSize="sm" fontWeight="bold" mb={0}>
|
||||
{config.title}
|
||||
</AlertTitle>
|
||||
<AlertDescription fontSize="sm" mb={0}>
|
||||
{config.description}
|
||||
</AlertDescription>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 重试按钮(仅失败状态显示) */}
|
||||
{status === CONNECTION_STATUS.FAILED && onRetry && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
leftIcon={<MdRefresh />}
|
||||
onClick={onRetry}
|
||||
mr={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
立即重试
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮(仅失败状态显示) */}
|
||||
{status === CONNECTION_STATUS.FAILED && onClose && (
|
||||
<CloseButton
|
||||
onClick={onClose}
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
/>
|
||||
)}
|
||||
</Alert>
|
||||
</Slide>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatusBar;
|
||||
@@ -3,7 +3,7 @@
|
||||
* 金融资讯通知容器组件 - 右下角层叠显示实时通知
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
@@ -14,25 +14,249 @@ import {
|
||||
Icon,
|
||||
Badge,
|
||||
Button,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Slide,
|
||||
ScaleFade,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess } from 'react-icons/md';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess, MdPerson, MdAccessTime } from 'react-icons/md';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import {
|
||||
NOTIFICATION_TYPE_CONFIGS,
|
||||
NOTIFICATION_TYPES,
|
||||
PRIORITY_CONFIGS,
|
||||
PRIORITY_LEVELS,
|
||||
NOTIFICATION_CONFIG,
|
||||
formatNotificationTime,
|
||||
getPriorityBgOpacity,
|
||||
getPriorityBorderWidth,
|
||||
} from '../../constants/notificationTypes';
|
||||
|
||||
/**
|
||||
* 单个通知项组件
|
||||
* 自定义 Hook:带过期时间的 localStorage 持久化状态
|
||||
* @param {string} key - localStorage 的 key
|
||||
* @param {*} initialValue - 初始值
|
||||
* @param {number} expiryMs - 过期时间(毫秒),0 表示不过期
|
||||
* @returns {[*, Function]} - [状态值, 设置函数]
|
||||
*/
|
||||
const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
const useLocalStorageWithExpiry = (key, initialValue, expiryMs = 0) => {
|
||||
// 从 localStorage 读取带过期时间的值
|
||||
const readValue = () => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (!item) {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
const { value, timestamp } = JSON.parse(item);
|
||||
|
||||
// 检查是否过期(仅当设置了过期时间时)
|
||||
if (expiryMs > 0 && timestamp) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
if (elapsed > expiryMs) {
|
||||
// 已过期,删除并返回初始值
|
||||
window.localStorage.removeItem(key);
|
||||
return initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${key} from localStorage:`, error);
|
||||
return initialValue;
|
||||
}
|
||||
};
|
||||
|
||||
const [storedValue, setStoredValue] = useState(readValue);
|
||||
|
||||
// 保存值到 localStorage(带时间戳)
|
||||
const setValue = (value) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
const item = {
|
||||
value: valueToStore,
|
||||
timestamp: Date.now(), // 保存时间戳
|
||||
};
|
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(item));
|
||||
} catch (error) {
|
||||
console.error(`Error saving ${key} to localStorage:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 storage 事件(跨标签页同步)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === key && e.newValue !== null) {
|
||||
try {
|
||||
const { value, timestamp } = JSON.parse(e.newValue);
|
||||
|
||||
// 检查是否过期
|
||||
if (expiryMs > 0 && timestamp) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
if (elapsed > expiryMs) {
|
||||
// 过期,设置为初始值
|
||||
setStoredValue(initialValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
setStoredValue(value);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing storage event for ${key}:`, error);
|
||||
}
|
||||
} else if (e.key === key && e.newValue === null) {
|
||||
// 其他标签页删除了该值
|
||||
setStoredValue(initialValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, [key, expiryMs, initialValue]);
|
||||
|
||||
// 定时检查过期(可选,更精确的过期控制)
|
||||
useEffect(() => {
|
||||
if (expiryMs <= 0) return; // 不需要过期检查
|
||||
|
||||
const checkExpiry = () => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (!item) return;
|
||||
|
||||
const { value, timestamp } = JSON.parse(item);
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
if (elapsed > expiryMs) {
|
||||
// 已过期,重置状态
|
||||
setStoredValue(initialValue);
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking expiry for ${key}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// 每10秒检查一次过期
|
||||
const intervalId = setInterval(checkExpiry, 10000);
|
||||
|
||||
// 立即执行一次检查
|
||||
checkExpiry();
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [key, expiryMs, initialValue]);
|
||||
|
||||
return [storedValue, setValue];
|
||||
};
|
||||
|
||||
/**
|
||||
* 辅助函数:生成通知的完整无障碍描述
|
||||
* @param {object} notification - 通知对象
|
||||
* @returns {string} - ARIA 描述文本
|
||||
*/
|
||||
const getNotificationDescription = (notification) => {
|
||||
const { type, priority, title, content, isAIGenerated, publishTime, pushTime, extra } = notification;
|
||||
|
||||
// 获取配置
|
||||
const typeConfig = NOTIFICATION_TYPE_CONFIGS[type] || NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.EVENT_ALERT];
|
||||
const priorityConfig = PRIORITY_CONFIGS[priority];
|
||||
|
||||
// 构建描述片段
|
||||
const parts = [];
|
||||
|
||||
// 优先级(如果需要显示)
|
||||
if (priorityConfig?.show) {
|
||||
parts.push(`${priorityConfig.label}通知`);
|
||||
}
|
||||
|
||||
// 类型
|
||||
parts.push(typeConfig.name);
|
||||
|
||||
// 标题
|
||||
parts.push(title);
|
||||
|
||||
// 内容
|
||||
if (content) {
|
||||
parts.push(content);
|
||||
}
|
||||
|
||||
// AI 生成标识
|
||||
if (isAIGenerated) {
|
||||
parts.push('由AI生成');
|
||||
}
|
||||
|
||||
// 预测标识
|
||||
if (extra?.isPrediction) {
|
||||
parts.push('预测状态');
|
||||
if (extra?.statusHint) {
|
||||
parts.push(extra.statusHint);
|
||||
}
|
||||
}
|
||||
|
||||
// 时间信息
|
||||
const time = publishTime || pushTime;
|
||||
if (time) {
|
||||
parts.push(`时间:${formatNotificationTime(time)}`);
|
||||
}
|
||||
|
||||
// 操作提示
|
||||
if (notification.clickable && notification.link) {
|
||||
parts.push('按回车键或空格键查看详情');
|
||||
}
|
||||
|
||||
return parts.join(',');
|
||||
};
|
||||
|
||||
/**
|
||||
* 辅助函数:处理键盘按键事件(Enter / Space)
|
||||
* @param {KeyboardEvent} event - 键盘事件
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
const handleKeyPress = (event, callback) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 紧急通知脉冲动画 - 边框颜色脉冲效果
|
||||
*/
|
||||
const pulseAnimation = keyframes`
|
||||
0%, 100% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: -4px 0 12px 0 currentColor;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 单个通知项组件
|
||||
* 使用 React.memo 优化,避免不必要的重渲染
|
||||
*/
|
||||
const NotificationItem = React.memo(({ notification, onClose, isNewest = false }) => {
|
||||
const navigate = useNavigate();
|
||||
const { trackNotificationClick } = useNotification();
|
||||
|
||||
// 加载状态管理 - 点击跳转时显示 loading
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const { id, type, priority, title, content, isAIGenerated, clickable, link, author, publishTime, pushTime, extra } = notification;
|
||||
|
||||
// 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
|
||||
@@ -51,77 +275,177 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
...typeConfig,
|
||||
icon: typeConfig.getIcon(priceChange),
|
||||
colorScheme: typeConfig.getColorScheme(priceChange),
|
||||
// 亮色模式
|
||||
bg: typeConfig.getBg(priceChange),
|
||||
borderColor: typeConfig.getBorderColor(priceChange),
|
||||
iconColor: typeConfig.getIconColor(priceChange),
|
||||
hoverBg: typeConfig.getHoverBg(priceChange),
|
||||
// 暗色模式
|
||||
darkBg: typeConfig.getDarkBg(priceChange),
|
||||
darkBorderColor: typeConfig.getDarkBorderColor(priceChange),
|
||||
darkIconColor: typeConfig.getDarkIconColor(priceChange),
|
||||
darkHoverBg: typeConfig.getDarkHoverBg(priceChange),
|
||||
};
|
||||
}
|
||||
|
||||
// 获取优先级配置
|
||||
const priorityConfig = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS.normal;
|
||||
|
||||
const bgColor = useColorModeValue(typeConfig.bg, `${typeConfig.colorScheme}.900`);
|
||||
const borderColor = useColorModeValue(typeConfig.borderColor, `${typeConfig.colorScheme}.500`);
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const metaTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const hoverBg = typeConfig.hoverBg;
|
||||
const closeButtonHoverBg = useColorModeValue(`${typeConfig.colorScheme}.200`, `${typeConfig.colorScheme}.700`);
|
||||
// 判断是否显示图标(仅紧急和重要通知)
|
||||
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT;
|
||||
|
||||
// 点击处理(只有真正可点击时才执行)
|
||||
const handleClick = () => {
|
||||
if (isActuallyClickable) {
|
||||
navigate(link);
|
||||
// 获取优先级样式
|
||||
const priorityBorderWidth = getPriorityBorderWidth(priority);
|
||||
const isDark = useColorModeValue(false, true);
|
||||
const priorityBgOpacity = getPriorityBgOpacity(priority, isDark);
|
||||
|
||||
// 根据优先级调整背景色深度
|
||||
const getPriorityBgColor = () => {
|
||||
const colorScheme = typeConfig.colorScheme;
|
||||
// 亮色模式:根据优先级使用不同深度的颜色
|
||||
if (!isDark) {
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return `${colorScheme}.200`; // 紧急:深色背景 + 脉冲动画
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return `${colorScheme}.100`; // 重要:中色背景
|
||||
} else {
|
||||
// 普通:极淡背景(使用 white 或 gray.50,降低视觉干扰)
|
||||
return 'white';
|
||||
}
|
||||
} else {
|
||||
// 暗色模式:使用 typeConfig 的 darkBg 或回退
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else {
|
||||
// 普通通知在暗色模式下使用更暗的灰色背景
|
||||
return 'gray.800';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 颜色配置 - 支持亮色/暗色模式(使用 useMemo 优化)
|
||||
const colors = useMemo(() => ({
|
||||
bg: getPriorityBgColor(),
|
||||
border: useColorModeValue(
|
||||
typeConfig.borderColor,
|
||||
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
|
||||
),
|
||||
icon: useColorModeValue(
|
||||
typeConfig.iconColor,
|
||||
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
|
||||
),
|
||||
text: useColorModeValue('gray.800', 'gray.100'),
|
||||
subText: useColorModeValue('gray.600', 'gray.300'),
|
||||
metaText: useColorModeValue('gray.500', 'gray.500'),
|
||||
hoverBg: useColorModeValue(
|
||||
typeConfig.hoverBg,
|
||||
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
|
||||
),
|
||||
closeButtonHoverBg: useColorModeValue(
|
||||
`${typeConfig.colorScheme}.200`,
|
||||
`${typeConfig.colorScheme}.700`
|
||||
),
|
||||
}), [isDark, priority, typeConfig]);
|
||||
|
||||
// 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化
|
||||
const handleClick = useCallback(() => {
|
||||
if (isActuallyClickable && !isNavigating) {
|
||||
// 设置加载状态
|
||||
setIsNavigating(true);
|
||||
|
||||
// 追踪点击(监控埋点)
|
||||
trackNotificationClick(id);
|
||||
|
||||
// 导航到目标页面
|
||||
navigate(link);
|
||||
|
||||
// 延迟关闭通知(给用户足够的视觉反馈 - 300ms)
|
||||
setTimeout(() => {
|
||||
onClose(id, true);
|
||||
}, 300);
|
||||
}
|
||||
}, [id, link, isActuallyClickable, isNavigating, trackNotificationClick, navigate, onClose]);
|
||||
|
||||
// 生成完整的无障碍描述
|
||||
const ariaDescription = getNotificationDescription(notification);
|
||||
|
||||
return (
|
||||
<ScaleFade initialScale={0.9} in={true}>
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderLeft="4px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
boxShadow={isNewest ? '2xl' : 'lg'}
|
||||
p={4}
|
||||
w="400px" // 统一宽度
|
||||
position="relative"
|
||||
cursor={isActuallyClickable ? 'pointer' : 'default'} // 严格判断
|
||||
onClick={isActuallyClickable ? handleClick : undefined} // 严格判断
|
||||
_hover={isActuallyClickable ? {
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
bg: hoverBg,
|
||||
} : {}} // 不可点击时无 hover 效果
|
||||
transition="all 0.2s"
|
||||
{...(isNewest && {
|
||||
borderRight: '1px solid',
|
||||
borderRightColor: borderColor,
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
|
||||
})}
|
||||
>
|
||||
{/* 头部区域:图标 + 标题 + 优先级 + AI标识 */}
|
||||
<Box
|
||||
// 无障碍属性
|
||||
role={priority === 'urgent' ? 'alert' : 'status'}
|
||||
aria-live={priority === 'urgent' ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
aria-label={ariaDescription}
|
||||
tabIndex={isActuallyClickable ? 0 : -1}
|
||||
onKeyDown={(e) => isActuallyClickable && handleKeyPress(e, handleClick)}
|
||||
// 样式属性
|
||||
bg={colors.bg}
|
||||
borderLeft={`${priorityBorderWidth} solid`}
|
||||
borderColor={colors.border}
|
||||
// 可点击的通知添加完整边框提示
|
||||
{...(isActuallyClickable && {
|
||||
border: '1px solid',
|
||||
borderLeftWidth: priorityBorderWidth, // 保持左侧优先级边框
|
||||
})}
|
||||
borderRadius="md"
|
||||
// 可点击的通知使用更明显的阴影(悬浮感)
|
||||
boxShadow={isActuallyClickable
|
||||
? (isNewest ? '2xl' : 'md')
|
||||
: (isNewest ? 'xl' : 'sm')}
|
||||
// 紧急通知添加脉冲动画
|
||||
animation={priority === PRIORITY_LEVELS.URGENT ? `${pulseAnimation} 2s ease-in-out infinite` : undefined}
|
||||
p={{ base: 3, md: 4 }}
|
||||
w={{ base: "calc(100vw - 32px)", sm: "360px", md: "380px", lg: "400px" }}
|
||||
maxW="400px"
|
||||
position="relative"
|
||||
cursor={isActuallyClickable ? (isNavigating ? 'wait' : 'pointer') : 'default'}
|
||||
onClick={isActuallyClickable && !isNavigating ? handleClick : undefined}
|
||||
opacity={isNavigating ? 0.7 : 1}
|
||||
pointerEvents={isNavigating ? 'none' : 'auto'}
|
||||
_hover={isActuallyClickable && !isNavigating ? {
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
bg: colors.hoverBg,
|
||||
} : {}} // 不可点击时无 hover 效果
|
||||
_focus={{
|
||||
outline: '2px solid',
|
||||
outlineColor: 'blue.500',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
willChange="transform, opacity" // 性能优化:GPU 加速
|
||||
{...(isNewest && {
|
||||
borderRight: '1px solid',
|
||||
borderRightColor: colors.border,
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
|
||||
})}
|
||||
>
|
||||
{/* 头部区域:标题 + 可选标识 */}
|
||||
<HStack spacing={2} align="start" mb={2}>
|
||||
{/* 类型图标 */}
|
||||
<Icon
|
||||
as={typeConfig.icon}
|
||||
w={5}
|
||||
h={5}
|
||||
color={typeConfig.iconColor}
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
/>
|
||||
{/* 类型图标 - 仅紧急和重要通知显示 */}
|
||||
{shouldShowIcon && (
|
||||
<Icon
|
||||
as={typeConfig.icon}
|
||||
w={5}
|
||||
h={5}
|
||||
color={colors.icon} // 使用响应式颜色
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题 */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
color={colors.text}
|
||||
lineHeight="short"
|
||||
flex={1}
|
||||
noOfLines={2}
|
||||
noOfLines={{ base: 1, md: 2 }}
|
||||
pl={shouldShowIcon ? 0 : 0}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
@@ -137,42 +461,27 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 预测标识 */}
|
||||
{isPrediction && (
|
||||
<Badge
|
||||
colorScheme="gray"
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
预测
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* AI 生成标识 */}
|
||||
{isAIGenerated && (
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
AI
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<IconButton
|
||||
icon={<MdClose />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme={typeConfig.colorScheme}
|
||||
aria-label="关闭通知"
|
||||
aria-label={`关闭通知:${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose(id);
|
||||
}
|
||||
}}
|
||||
flexShrink={0}
|
||||
_hover={{
|
||||
bg: closeButtonHoverBg,
|
||||
bg: colors.closeButtonHoverBg,
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
@@ -180,11 +489,11 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
{/* 内容区域 */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={subTextColor}
|
||||
color={colors.subText}
|
||||
lineHeight="short"
|
||||
noOfLines={3}
|
||||
noOfLines={{ base: 2, md: 3 }}
|
||||
mb={3}
|
||||
pl={7} // 与图标对齐
|
||||
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
@@ -193,61 +502,139 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
<HStack
|
||||
spacing={2}
|
||||
fontSize="xs"
|
||||
color={metaTextColor}
|
||||
pl={7} // 与图标对齐
|
||||
color={colors.metaText}
|
||||
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{/* 作者信息(仅分析报告) */}
|
||||
{author && (
|
||||
<HStack spacing={1}>
|
||||
<Text>👤</Text>
|
||||
<Text>{author.name} - {author.organization}</Text>
|
||||
<Text>|</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<HStack spacing={1}>
|
||||
<Text>📅</Text>
|
||||
<Icon as={MdAccessTime} w={3} h={3} />
|
||||
<Text>
|
||||
{publishTime && formatNotificationTime(publishTime)}
|
||||
{!publishTime && pushTime && formatNotificationTime(pushTime)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 状态提示(仅预测通知) */}
|
||||
{extra?.statusHint && (
|
||||
{/* AI 标识 - 小徽章(小屏及以上显示)*/}
|
||||
{isAIGenerated && (
|
||||
<>
|
||||
<Text>|</Text>
|
||||
<HStack spacing={1} color="gray.400">
|
||||
<Icon as={MdSchedule} w={3} h={3} />
|
||||
<Text>{extra.statusHint}</Text>
|
||||
<Text display={{ base: "none", sm: "inline" }}>|</Text>
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
size="xs"
|
||||
display={{ base: "none", sm: "inline-flex" }}
|
||||
>
|
||||
AI
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预测标识 + 状态提示 - 合并显示(小屏及以上)*/}
|
||||
{isPrediction && (
|
||||
<>
|
||||
<Text display={{ base: "none", sm: "inline" }}>|</Text>
|
||||
<HStack spacing={1} display={{ base: "none", sm: "flex" }}>
|
||||
<Badge colorScheme="gray" size="xs">预测</Badge>
|
||||
{extra?.statusHint && (
|
||||
<>
|
||||
<Icon as={MdSchedule} w={3} h={3} color="gray.400" />
|
||||
<Text color="gray.400">{extra.statusHint}</Text>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 可点击提示(仅真正可点击的通知) */}
|
||||
{/* 作者信息 - 仅当数据存在时显示(平板及以上)*/}
|
||||
{author && (
|
||||
<>
|
||||
<Text display={{ base: "none", md: "inline" }}>|</Text>
|
||||
<HStack spacing={1} display={{ base: "none", md: "flex" }}>
|
||||
<Icon as={MdPerson} w={3} h={3} />
|
||||
<Text>{author.name} - {author.organization}</Text>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 可点击提示(仅真正可点击的通知)*/}
|
||||
{isActuallyClickable && (
|
||||
<>
|
||||
<Text>|</Text>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={MdOpenInNew} w={3} h={3} />
|
||||
<Text>查看详情</Text>
|
||||
{/* Loading 时显示 Spinner,否则显示图标 */}
|
||||
{isNavigating ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<Icon as={MdOpenInNew} w={3} h={3} />
|
||||
)}
|
||||
{/* Loading 时显示"跳转中...",否则显示"查看详情" */}
|
||||
<Text color={isNavigating ? 'blue.500' : undefined}>
|
||||
{isNavigating ? '跳转中...' : '查看详情'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
</ScaleFade>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数:只在 id 或 isNewest 变化时重渲染
|
||||
return (
|
||||
prevProps.notification.id === nextProps.notification.id &&
|
||||
prevProps.isNewest === nextProps.isNewest
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 通知容器组件 - 主组件
|
||||
*/
|
||||
const NotificationContainer = () => {
|
||||
const { notifications, removeNotification } = useNotification();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// 使用带过期时间的 localStorage(2分钟 = 120000 毫秒)
|
||||
const [isExpanded, setIsExpanded] = useLocalStorageWithExpiry(
|
||||
'notification-expanded-state',
|
||||
false,
|
||||
120000
|
||||
);
|
||||
|
||||
// 追踪新通知(性能优化:只对新通知做动画)
|
||||
const prevNotificationIdsRef = useRef(new Set());
|
||||
const isFirstRenderRef = useRef(true);
|
||||
const [newNotificationIds, setNewNotificationIds] = useState(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// 首次渲染跳过动画检测
|
||||
if (isFirstRenderRef.current) {
|
||||
isFirstRenderRef.current = false;
|
||||
const currentIds = new Set(notifications.map(n => n.id));
|
||||
prevNotificationIdsRef.current = currentIds;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(notifications.map(n => n.id));
|
||||
const prevIds = prevNotificationIdsRef.current;
|
||||
|
||||
// 找出新增的通知 ID
|
||||
const newIds = new Set();
|
||||
currentIds.forEach(id => {
|
||||
if (!prevIds.has(id)) {
|
||||
newIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
setNewNotificationIds(newIds);
|
||||
|
||||
// 更新引用
|
||||
prevNotificationIdsRef.current = currentIds;
|
||||
|
||||
// 1秒后清除新通知标记(动画完成)
|
||||
if (newIds.size > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setNewNotificationIds(new Set());
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [notifications]);
|
||||
|
||||
// 如果没有通知,不渲染
|
||||
if (notifications.length === 0) {
|
||||
@@ -265,11 +652,18 @@ const NotificationContainer = () => {
|
||||
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 构建无障碍描述
|
||||
const containerAriaLabel = hasMore
|
||||
? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航,Enter键或空格键查看详情。`
|
||||
: `通知中心,共有 ${notifications.length} 条通知。使用Tab键导航,Enter键或空格键查看详情。`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
aria-label={containerAriaLabel}
|
||||
position="fixed"
|
||||
bottom={6}
|
||||
right={6}
|
||||
bottom={{ base: 3, md: 28 }}
|
||||
right={{ base: 3, md: 6 }}
|
||||
zIndex={9999}
|
||||
pointerEvents="none"
|
||||
>
|
||||
@@ -278,35 +672,56 @@ const NotificationContainer = () => {
|
||||
align="flex-end"
|
||||
pointerEvents="auto"
|
||||
>
|
||||
{visibleNotifications.map((notification, index) => (
|
||||
<Slide
|
||||
key={notification.id}
|
||||
direction="bottom"
|
||||
in={true}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 9999 - index, // 最新消息(index=0)z-index最高
|
||||
}}
|
||||
>
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onClose={removeNotification}
|
||||
isNewest={index === 0} // 第一条消息是最新的
|
||||
/>
|
||||
</Slide>
|
||||
))}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visibleNotifications.map((notification, index) => (
|
||||
<motion.div
|
||||
key={notification.id}
|
||||
layout // 自动处理位置变化(流畅重排)
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 300, scale: 0.9 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
mass: 0.8,
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 9999 - index,
|
||||
}}
|
||||
>
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onClose={removeNotification}
|
||||
isNewest={index === 0}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 折叠/展开按钮 */}
|
||||
{hasMore && (
|
||||
<ScaleFade initialScale={0.9} in={true}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
size={{ base: "xs", md: "sm" }}
|
||||
variant="solid"
|
||||
bg={collapseBg}
|
||||
color={collapseTextColor}
|
||||
_hover={{ bg: collapseHoverBg }}
|
||||
_focus={{
|
||||
outline: '2px solid',
|
||||
outlineColor: 'blue.500',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
leftIcon={<Icon as={isExpanded ? MdExpandLess : MdExpandMore} />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? '收起通知' : `展开查看还有 ${hiddenCount} 条通知`}
|
||||
boxShadow="md"
|
||||
borderRadius="md"
|
||||
>
|
||||
@@ -315,7 +730,7 @@ const NotificationContainer = () => {
|
||||
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
|
||||
}
|
||||
</Button>
|
||||
</ScaleFade>
|
||||
</motion.div>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -58,30 +58,66 @@ export const PRIORITY_CONFIGS = {
|
||||
[PRIORITY_LEVELS.URGENT]: {
|
||||
label: '紧急',
|
||||
colorScheme: 'red',
|
||||
show: true,
|
||||
show: false, // 不再显示标签,改用边框+背景色表示
|
||||
borderWidth: '6px', // 紧急:粗边框
|
||||
bgOpacity: 0.25, // 紧急:深色背景
|
||||
darkBgOpacity: 0.30, // 暗色模式下更明显
|
||||
},
|
||||
[PRIORITY_LEVELS.IMPORTANT]: {
|
||||
label: '重要',
|
||||
colorScheme: 'orange',
|
||||
show: true,
|
||||
show: false, // 不再显示标签,改用边框+背景色表示
|
||||
borderWidth: '4px', // 重要:中等边框
|
||||
bgOpacity: 0.15, // 重要:中色背景
|
||||
darkBgOpacity: 0.20, // 暗色模式
|
||||
},
|
||||
[PRIORITY_LEVELS.NORMAL]: {
|
||||
label: '',
|
||||
colorScheme: 'gray',
|
||||
show: false, // 普通优先级不显示标签
|
||||
borderWidth: '2px', // 普通:细边框
|
||||
bgOpacity: 0.08, // 普通:浅色背景
|
||||
darkBgOpacity: 0.12, // 暗色模式
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据优先级获取背景色透明度
|
||||
* @param {string} priority - 优先级
|
||||
* @param {boolean} isDark - 是否暗色模式
|
||||
* @returns {number} - 透明度值 (0-1)
|
||||
*/
|
||||
export const getPriorityBgOpacity = (priority, isDark = false) => {
|
||||
const config = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS[PRIORITY_LEVELS.NORMAL];
|
||||
return isDark ? config.darkBgOpacity : config.bgOpacity;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据优先级获取边框宽度
|
||||
* @param {string} priority - 优先级
|
||||
* @returns {string} - 边框宽度
|
||||
*/
|
||||
export const getPriorityBorderWidth = (priority) => {
|
||||
const config = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS[PRIORITY_LEVELS.NORMAL];
|
||||
return config.borderWidth;
|
||||
};
|
||||
|
||||
// 通知类型样式配置
|
||||
export const NOTIFICATION_TYPE_CONFIGS = {
|
||||
[NOTIFICATION_TYPES.ANNOUNCEMENT]: {
|
||||
name: '公告通知',
|
||||
icon: MdCampaign,
|
||||
colorScheme: 'blue',
|
||||
// 亮色模式
|
||||
bg: 'blue.50',
|
||||
borderColor: 'blue.400',
|
||||
iconColor: 'blue.500',
|
||||
hoverBg: 'blue.100',
|
||||
// 暗色模式
|
||||
darkBg: 'rgba(59, 130, 246, 0.15)', // blue.500 + 15% 透明度
|
||||
darkBorderColor: 'blue.400',
|
||||
darkIconColor: 'blue.300',
|
||||
darkHoverBg: 'rgba(59, 130, 246, 0.25)', // Hover 时 25% 透明度
|
||||
},
|
||||
[NOTIFICATION_TYPES.STOCK_ALERT]: {
|
||||
name: '股票动向',
|
||||
@@ -95,6 +131,7 @@ export const NOTIFICATION_TYPE_CONFIGS = {
|
||||
if (!priceChange) return 'red';
|
||||
return priceChange.startsWith('+') ? 'red' : 'green';
|
||||
},
|
||||
// 亮色模式
|
||||
getBg: (priceChange) => {
|
||||
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
||||
return `${scheme}.50`;
|
||||
@@ -111,24 +148,58 @@ export const NOTIFICATION_TYPE_CONFIGS = {
|
||||
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
||||
return `${scheme}.100`;
|
||||
},
|
||||
// 暗色模式
|
||||
getDarkBg: (priceChange) => {
|
||||
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
||||
// red (上涨): rgba(239, 68, 68, 0.15), green (下跌): rgba(34, 197, 94, 0.15)
|
||||
return scheme === 'red'
|
||||
? 'rgba(239, 68, 68, 0.15)'
|
||||
: 'rgba(34, 197, 94, 0.15)';
|
||||
},
|
||||
getDarkBorderColor: (priceChange) => {
|
||||
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
||||
return `${scheme}.400`;
|
||||
},
|
||||
getDarkIconColor: (priceChange) => {
|
||||
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
||||
return `${scheme}.300`;
|
||||
},
|
||||
getDarkHoverBg: (priceChange) => {
|
||||
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
||||
return scheme === 'red'
|
||||
? 'rgba(239, 68, 68, 0.25)'
|
||||
: 'rgba(34, 197, 94, 0.25)';
|
||||
},
|
||||
},
|
||||
[NOTIFICATION_TYPES.EVENT_ALERT]: {
|
||||
name: '事件动向',
|
||||
icon: MdArticle,
|
||||
colorScheme: 'orange',
|
||||
// 亮色模式
|
||||
bg: 'orange.50',
|
||||
borderColor: 'orange.400',
|
||||
iconColor: 'orange.500',
|
||||
hoverBg: 'orange.100',
|
||||
// 暗色模式
|
||||
darkBg: 'rgba(249, 115, 22, 0.15)', // orange.500 + 15% 透明度
|
||||
darkBorderColor: 'orange.400',
|
||||
darkIconColor: 'orange.300',
|
||||
darkHoverBg: 'rgba(249, 115, 22, 0.25)',
|
||||
},
|
||||
[NOTIFICATION_TYPES.ANALYSIS_REPORT]: {
|
||||
name: '分析报告',
|
||||
icon: MdAssessment,
|
||||
colorScheme: 'purple',
|
||||
// 亮色模式
|
||||
bg: 'purple.50',
|
||||
borderColor: 'purple.400',
|
||||
iconColor: 'purple.500',
|
||||
hoverBg: 'purple.100',
|
||||
// 暗色模式
|
||||
darkBg: 'rgba(168, 85, 247, 0.15)', // purple.500 + 15% 透明度
|
||||
darkBorderColor: 'purple.400',
|
||||
darkIconColor: 'purple.300',
|
||||
darkHoverBg: 'rgba(168, 85, 247, 0.25)',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -178,4 +249,6 @@ export default {
|
||||
PRIORITY_CONFIGS,
|
||||
NOTIFICATION_TYPE_CONFIGS,
|
||||
formatNotificationTime,
|
||||
getPriorityBgOpacity,
|
||||
getPriorityBorderWidth,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -329,7 +329,7 @@ class MockSocketService {
|
||||
|
||||
// 在连接后3秒发送欢迎消息
|
||||
setTimeout(() => {
|
||||
this.emit('trade_notification', {
|
||||
this.emit('new_event', {
|
||||
type: 'system_notification',
|
||||
severity: 'info',
|
||||
title: '连接成功',
|
||||
@@ -445,7 +445,7 @@ class MockSocketService {
|
||||
|
||||
// 延迟发送(模拟层叠效果)
|
||||
setTimeout(() => {
|
||||
this.emit('trade_notification', alert);
|
||||
this.emit('new_event', alert);
|
||||
logger.info('mockSocketService', 'Mock notification sent', alert);
|
||||
}, i * 500); // 每条消息间隔500ms
|
||||
}
|
||||
@@ -478,7 +478,7 @@ class MockSocketService {
|
||||
id: `test_${Date.now()}`,
|
||||
};
|
||||
|
||||
this.emit('trade_notification', notification);
|
||||
this.emit('new_event', notification);
|
||||
logger.info('mockSocketService', 'Test notification sent', notification);
|
||||
}
|
||||
|
||||
|
||||
402
src/services/notificationHistoryService.js
Normal file
402
src/services/notificationHistoryService.js
Normal file
@@ -0,0 +1,402 @@
|
||||
// src/services/notificationHistoryService.js
|
||||
/**
|
||||
* 通知历史记录服务
|
||||
* 持久化存储通知历史,支持查询、筛选、搜索、导出
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const STORAGE_KEY = 'notification_history';
|
||||
const MAX_HISTORY_SIZE = 500; // 最多保留 500 条历史记录
|
||||
|
||||
class NotificationHistoryService {
|
||||
constructor() {
|
||||
this.history = this.loadHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载历史记录
|
||||
*/
|
||||
loadHistory() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data);
|
||||
// 确保是数组
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('notificationHistoryService', 'loadHistory', error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存历史记录到 localStorage
|
||||
*/
|
||||
saveHistory() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
|
||||
logger.debug('notificationHistoryService', 'History saved', {
|
||||
count: this.history.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('notificationHistoryService', 'saveHistory', error);
|
||||
// localStorage 可能已满,尝试清理旧数据
|
||||
if (error.name === 'QuotaExceededError') {
|
||||
this.cleanup(100); // 清理 100 条
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
|
||||
} catch (retryError) {
|
||||
logger.error('notificationHistoryService', 'saveHistory retry failed', retryError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存通知到历史记录
|
||||
* @param {object} notification - 通知对象
|
||||
*/
|
||||
saveNotification(notification) {
|
||||
const record = {
|
||||
id: notification.id || `notif_${Date.now()}`,
|
||||
notification: { ...notification }, // 深拷贝
|
||||
receivedAt: Date.now(),
|
||||
readAt: null,
|
||||
clickedAt: null,
|
||||
};
|
||||
|
||||
// 检查是否已存在(去重)
|
||||
const existingIndex = this.history.findIndex(r => r.id === record.id);
|
||||
if (existingIndex !== -1) {
|
||||
logger.debug('notificationHistoryService', 'Notification already exists', { id: record.id });
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到历史记录开头
|
||||
this.history.unshift(record);
|
||||
|
||||
// 限制最大数量
|
||||
if (this.history.length > MAX_HISTORY_SIZE) {
|
||||
this.history = this.history.slice(0, MAX_HISTORY_SIZE);
|
||||
}
|
||||
|
||||
this.saveHistory();
|
||||
|
||||
logger.info('notificationHistoryService', 'Notification saved', { id: record.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记通知为已读
|
||||
* @param {string} id - 通知 ID
|
||||
*/
|
||||
markAsRead(id) {
|
||||
const record = this.history.find(r => r.id === id);
|
||||
if (record && !record.readAt) {
|
||||
record.readAt = Date.now();
|
||||
this.saveHistory();
|
||||
logger.debug('notificationHistoryService', 'Marked as read', { id });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记通知为已点击
|
||||
* @param {string} id - 通知 ID
|
||||
*/
|
||||
markAsClicked(id) {
|
||||
const record = this.history.find(r => r.id === id);
|
||||
if (record && !record.clickedAt) {
|
||||
record.clickedAt = Date.now();
|
||||
// 点击也意味着已读
|
||||
if (!record.readAt) {
|
||||
record.readAt = Date.now();
|
||||
}
|
||||
this.saveHistory();
|
||||
logger.debug('notificationHistoryService', 'Marked as clicked', { id });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史记录
|
||||
* @param {object} filters - 筛选条件
|
||||
* @param {string} filters.type - 通知类型
|
||||
* @param {string} filters.priority - 优先级
|
||||
* @param {string} filters.readStatus - 阅读状态 ('read' | 'unread' | 'all')
|
||||
* @param {number} filters.startDate - 开始日期(时间戳)
|
||||
* @param {number} filters.endDate - 结束日期(时间戳)
|
||||
* @param {number} filters.page - 页码(从 1 开始)
|
||||
* @param {number} filters.pageSize - 每页数量
|
||||
* @returns {object} - { records, total, page, pageSize }
|
||||
*/
|
||||
getHistory(filters = {}) {
|
||||
let filtered = [...this.history];
|
||||
|
||||
// 按类型筛选
|
||||
if (filters.type && filters.type !== 'all') {
|
||||
filtered = filtered.filter(r => r.notification.type === filters.type);
|
||||
}
|
||||
|
||||
// 按优先级筛选
|
||||
if (filters.priority && filters.priority !== 'all') {
|
||||
filtered = filtered.filter(r => r.notification.priority === filters.priority);
|
||||
}
|
||||
|
||||
// 按阅读状态筛选
|
||||
if (filters.readStatus === 'read') {
|
||||
filtered = filtered.filter(r => r.readAt !== null);
|
||||
} else if (filters.readStatus === 'unread') {
|
||||
filtered = filtered.filter(r => r.readAt === null);
|
||||
}
|
||||
|
||||
// 按日期范围筛选
|
||||
if (filters.startDate) {
|
||||
filtered = filtered.filter(r => r.receivedAt >= filters.startDate);
|
||||
}
|
||||
if (filters.endDate) {
|
||||
filtered = filtered.filter(r => r.receivedAt <= filters.endDate);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = filters.page || 1;
|
||||
const pageSize = filters.pageSize || 20;
|
||||
const total = filtered.length;
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const records = filtered.slice(startIndex, endIndex);
|
||||
|
||||
return {
|
||||
records,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索历史记录
|
||||
* @param {string} keyword - 搜索关键词
|
||||
* @returns {array} - 匹配的记录
|
||||
*/
|
||||
searchHistory(keyword) {
|
||||
if (!keyword || keyword.trim() === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
|
||||
const results = this.history.filter(record => {
|
||||
const { title, content, message } = record.notification;
|
||||
const searchText = `${title || ''} ${content || ''} ${message || ''}`.toLowerCase();
|
||||
return searchText.includes(lowerKeyword);
|
||||
});
|
||||
|
||||
logger.info('notificationHistoryService', 'Search completed', {
|
||||
keyword,
|
||||
resultsCount: results.length
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条历史记录
|
||||
* @param {string} id - 通知 ID
|
||||
*/
|
||||
deleteRecord(id) {
|
||||
const initialLength = this.history.length;
|
||||
this.history = this.history.filter(r => r.id !== id);
|
||||
|
||||
if (this.history.length < initialLength) {
|
||||
this.saveHistory();
|
||||
logger.info('notificationHistoryService', 'Record deleted', { id });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除历史记录
|
||||
* @param {array} ids - 通知 ID 数组
|
||||
*/
|
||||
deleteRecords(ids) {
|
||||
const initialLength = this.history.length;
|
||||
this.history = this.history.filter(r => !ids.includes(r.id));
|
||||
|
||||
const deletedCount = initialLength - this.history.length;
|
||||
|
||||
if (deletedCount > 0) {
|
||||
this.saveHistory();
|
||||
logger.info('notificationHistoryService', 'Batch delete completed', {
|
||||
deletedCount
|
||||
});
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有历史记录
|
||||
*/
|
||||
clearHistory() {
|
||||
this.history = [];
|
||||
this.saveHistory();
|
||||
logger.info('notificationHistoryService', 'History cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧数据
|
||||
* @param {number} count - 要清理的数量
|
||||
*/
|
||||
cleanup(count) {
|
||||
if (this.history.length > count) {
|
||||
this.history = this.history.slice(0, -count);
|
||||
this.saveHistory();
|
||||
logger.info('notificationHistoryService', 'Cleanup completed', { count });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计数据
|
||||
* @returns {object} - 统计信息
|
||||
*/
|
||||
getStats() {
|
||||
const total = this.history.length;
|
||||
const read = this.history.filter(r => r.readAt !== null).length;
|
||||
const unread = total - read;
|
||||
const clicked = this.history.filter(r => r.clickedAt !== null).length;
|
||||
|
||||
// 按类型统计
|
||||
const byType = {};
|
||||
const byPriority = {};
|
||||
|
||||
this.history.forEach(record => {
|
||||
const { type, priority } = record.notification;
|
||||
|
||||
// 类型统计
|
||||
if (type) {
|
||||
byType[type] = (byType[type] || 0) + 1;
|
||||
}
|
||||
|
||||
// 优先级统计
|
||||
if (priority) {
|
||||
byPriority[priority] = (byPriority[priority] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// 计算点击率
|
||||
const clickRate = total > 0 ? ((clicked / total) * 100).toFixed(2) : '0.00';
|
||||
|
||||
return {
|
||||
total,
|
||||
read,
|
||||
unread,
|
||||
clicked,
|
||||
clickRate,
|
||||
byType,
|
||||
byPriority,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出历史记录为 JSON
|
||||
* @param {object} filters - 筛选条件(可选)
|
||||
*/
|
||||
exportToJSON(filters = {}) {
|
||||
const { records } = this.getHistory(filters);
|
||||
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
total: records.length,
|
||||
records: records.map(r => ({
|
||||
id: r.id,
|
||||
notification: r.notification,
|
||||
receivedAt: new Date(r.receivedAt).toISOString(),
|
||||
readAt: r.readAt ? new Date(r.readAt).toISOString() : null,
|
||||
clickedAt: r.clickedAt ? new Date(r.clickedAt).toISOString() : null,
|
||||
})),
|
||||
};
|
||||
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出历史记录为 CSV
|
||||
* @param {object} filters - 筛选条件(可选)
|
||||
*/
|
||||
exportToCSV(filters = {}) {
|
||||
const { records } = this.getHistory(filters);
|
||||
|
||||
let csv = 'ID,Type,Priority,Title,Content,Received At,Read At,Clicked At\n';
|
||||
|
||||
records.forEach(record => {
|
||||
const { id, notification, receivedAt, readAt, clickedAt } = record;
|
||||
const { type, priority, title, content, message } = notification;
|
||||
|
||||
const escapeCsv = (str) => {
|
||||
if (!str) return '';
|
||||
return `"${String(str).replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
csv += [
|
||||
escapeCsv(id),
|
||||
escapeCsv(type),
|
||||
escapeCsv(priority),
|
||||
escapeCsv(title),
|
||||
escapeCsv(content || message),
|
||||
new Date(receivedAt).toISOString(),
|
||||
readAt ? new Date(readAt).toISOString() : '',
|
||||
clickedAt ? new Date(clickedAt).toISOString() : '',
|
||||
].join(',') + '\n';
|
||||
});
|
||||
|
||||
return csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发下载文件
|
||||
* @param {string} content - 文件内容
|
||||
* @param {string} filename - 文件名
|
||||
* @param {string} mimeType - MIME 类型
|
||||
*/
|
||||
downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
logger.info('notificationHistoryService', 'File downloaded', { filename });
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出并下载 JSON 文件
|
||||
* @param {object} filters - 筛选条件(可选)
|
||||
*/
|
||||
downloadJSON(filters = {}) {
|
||||
const json = this.exportToJSON(filters);
|
||||
const filename = `notifications_${new Date().toISOString().split('T')[0]}.json`;
|
||||
this.downloadFile(json, filename, 'application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出并下载 CSV 文件
|
||||
* @param {object} filters - 筛选条件(可选)
|
||||
*/
|
||||
downloadCSV(filters = {}) {
|
||||
const csv = this.exportToCSV(filters);
|
||||
const filename = `notifications_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
this.downloadFile(csv, filename, 'text/csv');
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const notificationHistoryService = new NotificationHistoryService();
|
||||
|
||||
export default notificationHistoryService;
|
||||
450
src/services/notificationMetricsService.js
Normal file
450
src/services/notificationMetricsService.js
Normal file
@@ -0,0 +1,450 @@
|
||||
// src/services/notificationMetricsService.js
|
||||
/**
|
||||
* 通知性能监控服务
|
||||
* 追踪推送到达率、点击率、响应时间等指标
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const STORAGE_KEY = 'notification_metrics';
|
||||
const MAX_HISTORY_DAYS = 30; // 保留最近 30 天数据
|
||||
|
||||
class NotificationMetricsService {
|
||||
constructor() {
|
||||
this.metrics = this.loadMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载指标数据
|
||||
*/
|
||||
loadMetrics() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data);
|
||||
// 清理过期数据
|
||||
this.cleanOldData(parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('notificationMetricsService', 'loadMetrics', error);
|
||||
}
|
||||
|
||||
// 返回默认结构
|
||||
return this.getDefaultMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认指标结构
|
||||
*/
|
||||
getDefaultMetrics() {
|
||||
return {
|
||||
summary: {
|
||||
totalSent: 0,
|
||||
totalReceived: 0,
|
||||
totalClicked: 0,
|
||||
totalDismissed: 0,
|
||||
totalResponseTime: 0, // 总响应时间(毫秒)
|
||||
},
|
||||
byType: {
|
||||
announcement: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||||
stock_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||||
event_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||||
analysis_report: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||||
},
|
||||
byPriority: {
|
||||
urgent: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||||
important: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||||
normal: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
|
||||
},
|
||||
hourlyDistribution: Array(24).fill(0), // 每小时推送分布
|
||||
dailyData: {}, // 按日期存储的每日数据
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存指标数据到 localStorage
|
||||
*/
|
||||
saveMetrics() {
|
||||
try {
|
||||
this.metrics.lastUpdated = Date.now();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.metrics));
|
||||
logger.debug('notificationMetricsService', 'Metrics saved', this.metrics.summary);
|
||||
} catch (error) {
|
||||
logger.error('notificationMetricsService', 'saveMetrics', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期数据
|
||||
*/
|
||||
cleanOldData(metrics) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - MAX_HISTORY_DAYS);
|
||||
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
|
||||
|
||||
if (metrics.dailyData) {
|
||||
Object.keys(metrics.dailyData).forEach(date => {
|
||||
if (date < cutoffDateStr) {
|
||||
delete metrics.dailyData[date];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前日期字符串
|
||||
*/
|
||||
getTodayDateStr() {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前小时
|
||||
*/
|
||||
getCurrentHour() {
|
||||
return new Date().getHours();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保每日数据结构存在
|
||||
*/
|
||||
ensureDailyData(dateStr) {
|
||||
if (!this.metrics.dailyData[dateStr]) {
|
||||
this.metrics.dailyData[dateStr] = {
|
||||
sent: 0,
|
||||
received: 0,
|
||||
clicked: 0,
|
||||
dismissed: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪通知发送
|
||||
* @param {object} notification - 通知对象
|
||||
*/
|
||||
trackSent(notification) {
|
||||
const { type, priority } = notification;
|
||||
const today = this.getTodayDateStr();
|
||||
const hour = this.getCurrentHour();
|
||||
|
||||
// 汇总统计
|
||||
this.metrics.summary.totalSent++;
|
||||
|
||||
// 按类型统计
|
||||
if (this.metrics.byType[type]) {
|
||||
this.metrics.byType[type].sent++;
|
||||
}
|
||||
|
||||
// 按优先级统计
|
||||
if (priority && this.metrics.byPriority[priority]) {
|
||||
this.metrics.byPriority[priority].sent++;
|
||||
}
|
||||
|
||||
// 每小时分布
|
||||
this.metrics.hourlyDistribution[hour]++;
|
||||
|
||||
// 每日数据
|
||||
this.ensureDailyData(today);
|
||||
this.metrics.dailyData[today].sent++;
|
||||
|
||||
this.saveMetrics();
|
||||
|
||||
logger.debug('notificationMetricsService', 'Tracked sent', { type, priority });
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪通知接收
|
||||
* @param {object} notification - 通知对象
|
||||
*/
|
||||
trackReceived(notification) {
|
||||
const { type, priority, id, timestamp } = notification;
|
||||
const today = this.getTodayDateStr();
|
||||
|
||||
// 汇总统计
|
||||
this.metrics.summary.totalReceived++;
|
||||
|
||||
// 按类型统计
|
||||
if (this.metrics.byType[type]) {
|
||||
this.metrics.byType[type].received++;
|
||||
}
|
||||
|
||||
// 按优先级统计
|
||||
if (priority && this.metrics.byPriority[priority]) {
|
||||
this.metrics.byPriority[priority].received++;
|
||||
}
|
||||
|
||||
// 每日数据
|
||||
this.ensureDailyData(today);
|
||||
this.metrics.dailyData[today].received++;
|
||||
|
||||
// 存储接收时间(用于计算响应时间)
|
||||
this.storeReceivedTime(id, timestamp || Date.now());
|
||||
|
||||
this.saveMetrics();
|
||||
|
||||
logger.debug('notificationMetricsService', 'Tracked received', { type, priority });
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪通知点击
|
||||
* @param {object} notification - 通知对象
|
||||
*/
|
||||
trackClicked(notification) {
|
||||
const { type, priority, id } = notification;
|
||||
const today = this.getTodayDateStr();
|
||||
const clickTime = Date.now();
|
||||
|
||||
// 汇总统计
|
||||
this.metrics.summary.totalClicked++;
|
||||
|
||||
// 按类型统计
|
||||
if (this.metrics.byType[type]) {
|
||||
this.metrics.byType[type].clicked++;
|
||||
}
|
||||
|
||||
// 按优先级统计
|
||||
if (priority && this.metrics.byPriority[priority]) {
|
||||
this.metrics.byPriority[priority].clicked++;
|
||||
}
|
||||
|
||||
// 每日数据
|
||||
this.ensureDailyData(today);
|
||||
this.metrics.dailyData[today].clicked++;
|
||||
|
||||
// 计算响应时间
|
||||
const receivedTime = this.getReceivedTime(id);
|
||||
if (receivedTime) {
|
||||
const responseTime = clickTime - receivedTime;
|
||||
this.metrics.summary.totalResponseTime += responseTime;
|
||||
this.removeReceivedTime(id);
|
||||
logger.debug('notificationMetricsService', 'Response time', { responseTime });
|
||||
}
|
||||
|
||||
this.saveMetrics();
|
||||
|
||||
logger.debug('notificationMetricsService', 'Tracked clicked', { type, priority });
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪通知关闭
|
||||
* @param {object} notification - 通知对象
|
||||
*/
|
||||
trackDismissed(notification) {
|
||||
const { type, priority, id } = notification;
|
||||
const today = this.getTodayDateStr();
|
||||
|
||||
// 汇总统计
|
||||
this.metrics.summary.totalDismissed++;
|
||||
|
||||
// 按类型统计
|
||||
if (this.metrics.byType[type]) {
|
||||
this.metrics.byType[type].dismissed++;
|
||||
}
|
||||
|
||||
// 按优先级统计
|
||||
if (priority && this.metrics.byPriority[priority]) {
|
||||
this.metrics.byPriority[priority].dismissed++;
|
||||
}
|
||||
|
||||
// 每日数据
|
||||
this.ensureDailyData(today);
|
||||
this.metrics.dailyData[today].dismissed++;
|
||||
|
||||
// 清理接收时间记录
|
||||
this.removeReceivedTime(id);
|
||||
|
||||
this.saveMetrics();
|
||||
|
||||
logger.debug('notificationMetricsService', 'Tracked dismissed', { type, priority });
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储通知接收时间(用于计算响应时间)
|
||||
*/
|
||||
storeReceivedTime(id, timestamp) {
|
||||
if (!this.receivedTimes) {
|
||||
this.receivedTimes = new Map();
|
||||
}
|
||||
this.receivedTimes.set(id, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通知接收时间
|
||||
*/
|
||||
getReceivedTime(id) {
|
||||
if (!this.receivedTimes) {
|
||||
return null;
|
||||
}
|
||||
return this.receivedTimes.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除通知接收时间记录
|
||||
*/
|
||||
removeReceivedTime(id) {
|
||||
if (this.receivedTimes) {
|
||||
this.receivedTimes.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取汇总统计
|
||||
*/
|
||||
getSummary() {
|
||||
const summary = { ...this.metrics.summary };
|
||||
|
||||
// 计算平均响应时间
|
||||
if (summary.totalClicked > 0) {
|
||||
summary.avgResponseTime = Math.round(summary.totalResponseTime / summary.totalClicked);
|
||||
} else {
|
||||
summary.avgResponseTime = 0;
|
||||
}
|
||||
|
||||
// 计算点击率
|
||||
if (summary.totalReceived > 0) {
|
||||
summary.clickRate = ((summary.totalClicked / summary.totalReceived) * 100).toFixed(2);
|
||||
} else {
|
||||
summary.clickRate = '0.00';
|
||||
}
|
||||
|
||||
// 计算到达率(假设 sent = received)
|
||||
if (summary.totalSent > 0) {
|
||||
summary.deliveryRate = ((summary.totalReceived / summary.totalSent) * 100).toFixed(2);
|
||||
} else {
|
||||
summary.deliveryRate = '100.00';
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按类型统计
|
||||
*/
|
||||
getByType() {
|
||||
const result = {};
|
||||
Object.keys(this.metrics.byType).forEach(type => {
|
||||
const data = this.metrics.byType[type];
|
||||
result[type] = {
|
||||
...data,
|
||||
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
|
||||
};
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按优先级统计
|
||||
*/
|
||||
getByPriority() {
|
||||
const result = {};
|
||||
Object.keys(this.metrics.byPriority).forEach(priority => {
|
||||
const data = this.metrics.byPriority[priority];
|
||||
result[priority] = {
|
||||
...data,
|
||||
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
|
||||
};
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取每小时分布
|
||||
*/
|
||||
getHourlyDistribution() {
|
||||
return [...this.metrics.hourlyDistribution];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取每日数据
|
||||
* @param {number} days - 获取最近多少天的数据
|
||||
*/
|
||||
getDailyData(days = 7) {
|
||||
const result = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
const data = this.metrics.dailyData[dateStr] || {
|
||||
sent: 0,
|
||||
received: 0,
|
||||
clicked: 0,
|
||||
dismissed: 0,
|
||||
};
|
||||
|
||||
result.push({
|
||||
date: dateStr,
|
||||
...data,
|
||||
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整指标数据
|
||||
*/
|
||||
getAllMetrics() {
|
||||
return {
|
||||
summary: this.getSummary(),
|
||||
byType: this.getByType(),
|
||||
byPriority: this.getByPriority(),
|
||||
hourlyDistribution: this.getHourlyDistribution(),
|
||||
dailyData: this.getDailyData(30),
|
||||
lastUpdated: this.metrics.lastUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有指标
|
||||
*/
|
||||
reset() {
|
||||
this.metrics = this.getDefaultMetrics();
|
||||
this.saveMetrics();
|
||||
logger.info('notificationMetricsService', 'Metrics reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出指标数据为 JSON
|
||||
*/
|
||||
exportToJSON() {
|
||||
return JSON.stringify(this.getAllMetrics(), null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出指标数据为 CSV
|
||||
*/
|
||||
exportToCSV() {
|
||||
const summary = this.getSummary();
|
||||
const dailyData = this.getDailyData(30);
|
||||
|
||||
let csv = '# Summary\n';
|
||||
csv += 'Metric,Value\n';
|
||||
csv += `Total Sent,${summary.totalSent}\n`;
|
||||
csv += `Total Received,${summary.totalReceived}\n`;
|
||||
csv += `Total Clicked,${summary.totalClicked}\n`;
|
||||
csv += `Total Dismissed,${summary.totalDismissed}\n`;
|
||||
csv += `Delivery Rate,${summary.deliveryRate}%\n`;
|
||||
csv += `Click Rate,${summary.clickRate}%\n`;
|
||||
csv += `Avg Response Time,${summary.avgResponseTime}ms\n\n`;
|
||||
|
||||
csv += '# Daily Data\n';
|
||||
csv += 'Date,Sent,Received,Clicked,Dismissed,Click Rate\n';
|
||||
dailyData.forEach(day => {
|
||||
csv += `${day.date},${day.sent},${day.received},${day.clicked},${day.dismissed},${day.clickRate}%\n`;
|
||||
});
|
||||
|
||||
return csv;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const notificationMetricsService = new NotificationMetricsService();
|
||||
|
||||
export default notificationMetricsService;
|
||||
@@ -187,6 +187,46 @@ class SocketService {
|
||||
return this.socket?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重连
|
||||
* @returns {boolean} 是否触发重连
|
||||
*/
|
||||
reconnect() {
|
||||
if (!this.socket) {
|
||||
logger.warn('socketService', 'Cannot reconnect: socket not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.connected) {
|
||||
logger.info('socketService', 'Already connected, no need to reconnect');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('socketService', 'Manually triggering reconnection...');
|
||||
|
||||
// 重置重连计数
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 触发重连
|
||||
this.socket.connect();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前重连尝试次数
|
||||
*/
|
||||
getReconnectAttempts() {
|
||||
return this.reconnectAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大重连次数
|
||||
*/
|
||||
getMaxReconnectAttempts() {
|
||||
return this.maxReconnectAttempts;
|
||||
}
|
||||
|
||||
// ==================== 事件推送专用方法 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ViewIcon,
|
||||
@@ -53,6 +54,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import { useEventNotifications } from '../../../hooks/useEventNotifications';
|
||||
|
||||
// ========== 工具函数定义在组件外部 ==========
|
||||
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
||||
@@ -166,15 +168,55 @@ const PriceArrow = ({ value }) => {
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表
|
||||
|
||||
// 实时事件推送集成
|
||||
const { isConnected } = useEventNotifications({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
enabled: true,
|
||||
onNewEvent: (event) => {
|
||||
logger.info('EventList', '收到新事件推送', event);
|
||||
|
||||
// 显示 Toast 通知
|
||||
toast({
|
||||
title: '新事件发布',
|
||||
description: event.title,
|
||||
status: 'info',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
variant: 'left-accent',
|
||||
});
|
||||
|
||||
// 将新事件添加到列表顶部(防止重复)
|
||||
setLocalEvents((prevEvents) => {
|
||||
const exists = prevEvents.some(e => e.id === event.id);
|
||||
if (exists) {
|
||||
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
|
||||
return prevEvents;
|
||||
}
|
||||
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
|
||||
// 添加到顶部,最多保留 100 个
|
||||
return [event, ...prevEvents].slice(0, 100);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 同步外部 events 到 localEvents
|
||||
useEffect(() => {
|
||||
setLocalEvents(events);
|
||||
}, [events]);
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
events.forEach(ev => {
|
||||
localEvents.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
@@ -197,8 +239,8 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 events 更新时重跑
|
||||
}, [events]);
|
||||
// 仅在 localEvents 更新时重跑
|
||||
}, [localEvents]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
@@ -766,8 +808,27 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" py={8}>
|
||||
<Container maxW="container.xl">
|
||||
{/* 视图切换控制 */}
|
||||
<Flex justify="flex-end" mb={6}>
|
||||
{/* 顶部控制栏:连接状态 + 视图切换 */}
|
||||
<Flex justify="space-between" align="center" mb={6}>
|
||||
{/* WebSocket 连接状态指示器 */}
|
||||
<HStack spacing={2}>
|
||||
<Badge
|
||||
colorScheme={isConnected ? 'green' : 'red'}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{isConnected ? '🟢 实时推送已开启' : '🔴 实时推送未连接'}
|
||||
</Badge>
|
||||
{isConnected && (
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
新事件将自动推送
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 视图切换控制 */}
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
|
||||
精简模式
|
||||
@@ -781,11 +842,11 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
</FormControl>
|
||||
</Flex>
|
||||
|
||||
{events.length > 0 ? (
|
||||
{localEvents.length > 0 ? (
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{events.map((event, index) => (
|
||||
{localEvents.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
{isCompactMode
|
||||
{isCompactMode
|
||||
? renderCompactEvent(event)
|
||||
: renderDetailedEvent(event)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user