Files
vf_react/src/components/NotificationContainer/index.js
2025-10-21 17:50:21 +08:00

741 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/components/NotificationContainer/index.js
/**
* 金融资讯通知容器组件 - 右下角层叠显示实时通知
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
VStack,
HStack,
Text,
IconButton,
Icon,
Badge,
Button,
Spinner,
useColorModeValue,
} from '@chakra-ui/react';
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 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 存在才可点击
const isActuallyClickable = clickable && link;
// 判断是否为预测通知
const isPrediction = extra?.isPrediction;
// 获取类型配置
let typeConfig = NOTIFICATION_TYPE_CONFIGS[type] || NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.EVENT_ALERT];
// 股票动向需要根据涨跌动态配置
if (type === NOTIFICATION_TYPES.STOCK_ALERT && extra?.priceChange) {
const priceChange = extra.priceChange;
typeConfig = {
...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 shouldShowIcon = priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT;
// 获取优先级样式
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 (
<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}>
{/* 类型图标 - 仅紧急和重要通知显示 */}
{shouldShowIcon && (
<Icon
as={typeConfig.icon}
w={5}
h={5}
color={colors.icon} // 使用响应式颜色
mt={0.5}
flexShrink={0}
/>
)}
{/* 标题 */}
<Text
fontSize="sm"
fontWeight="bold"
color={colors.text}
lineHeight="short"
flex={1}
noOfLines={{ base: 1, md: 2 }}
pl={shouldShowIcon ? 0 : 0}
>
{title}
</Text>
{/* 优先级标签 */}
{priorityConfig.show && (
<Badge
colorScheme={priorityConfig.colorScheme}
size="sm"
flexShrink={0}
>
{priorityConfig.label}
</Badge>
)}
{/* 关闭按钮 */}
<IconButton
icon={<MdClose />}
size="xs"
variant="ghost"
colorScheme={typeConfig.colorScheme}
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: colors.closeButtonHoverBg,
}}
/>
</HStack>
{/* 内容区域 */}
<Text
fontSize="sm"
color={colors.subText}
lineHeight="short"
noOfLines={{ base: 2, md: 3 }}
mb={3}
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
>
{content}
</Text>
{/* 底部元数据区域 */}
<HStack
spacing={2}
fontSize="xs"
color={colors.metaText}
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
flexWrap="wrap"
>
{/* 时间信息 */}
<HStack spacing={1}>
<Icon as={MdAccessTime} w={3} h={3} />
<Text>
{publishTime && formatNotificationTime(publishTime)}
{!publishTime && pushTime && formatNotificationTime(pushTime)}
</Text>
</HStack>
{/* AI 标识 - 小徽章(小屏及以上显示)*/}
{isAIGenerated && (
<>
<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}>
{/* 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>
);
}, (prevProps, nextProps) => {
// 自定义比较函数:只在 id 或 isNewest 变化时重渲染
return (
prevProps.notification.id === nextProps.notification.id &&
prevProps.isNewest === nextProps.isNewest
);
});
/**
* 通知容器组件 - 主组件
*/
const NotificationContainer = () => {
const { notifications, removeNotification } = useNotification();
// 使用带过期时间的 localStorage2分钟 = 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) {
return null;
}
// 根据展开状态决定显示的通知
const maxVisible = NOTIFICATION_CONFIG.maxVisible;
const hasMore = notifications.length > maxVisible;
const visibleNotifications = isExpanded ? notifications : notifications.slice(0, maxVisible);
const hiddenCount = notifications.length - maxVisible;
// 颜色配置
const collapseBg = useColorModeValue('gray.100', 'gray.700');
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={{ base: 3, md: 28 }}
right={{ base: 3, md: 6 }}
zIndex={9999}
pointerEvents="none"
>
<VStack
spacing={3} // 消息之间间距 12px
align="flex-end"
pointerEvents="auto"
>
<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 && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.2 }}
>
<Button
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"
>
{isExpanded
? '收起通知'
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
}
</Button>
</motion.div>
)}
</VStack>
</Box>
);
};
export default NotificationContainer;