741 lines
27 KiB
JavaScript
741 lines
27 KiB
JavaScript
// 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();
|
||
// 使用带过期时间的 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) {
|
||
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;
|