feat: sockt 弹窗功能添加

This commit is contained in:
zdl
2025-10-21 17:50:21 +08:00
parent c93f689954
commit 09c9273190
17 changed files with 3739 additions and 161 deletions

View 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;

View File

@@ -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);
// 使用带过期时间的 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) {
@@ -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=0z-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>