| 1 | /** |
| 2 | * NotificationCenter - 全局通知中心组件 |
| 3 | * 显示不同类型的通知(success/error/warning/info/loading) |
| 4 | * 支持鼠标悬停暂停自动关闭、手动关闭等功能 |
| 5 | */ |
| 6 | 'use client' |
| 7 | |
| 8 | import { AlertCircle, CheckCircle2, Info, Loader2, X, XCircle } from 'lucide-react' |
| 9 | import React, { useEffect, useRef, useState } from 'react' |
| 10 | import { cn } from '@/utils/className' |
| 11 | |
| 12 | type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading' |
| 13 | |
| 14 | interface NotificationDetail { |
| 15 | key?: string |
| 16 | id?: string |
| 17 | _uid?: string |
| 18 | content?: React.ReactNode |
| 19 | duration?: number |
| 20 | type?: NotificationType |
| 21 | } |
| 22 | |
| 23 | interface NotificationItem { |
| 24 | uid: string |
| 25 | key?: string |
| 26 | content: React.ReactNode |
| 27 | duration: number |
| 28 | expiresAt: number |
| 29 | visible: boolean |
| 30 | type: NotificationType |
| 31 | isPaused: boolean |
| 32 | } |
| 33 | |
| 34 | function genUid() { |
| 35 | return `${Date.now()}-${Math.floor(Math.random() * 100000)}` |
| 36 | } |
| 37 | |
| 38 | // 通知图标配置 |
| 39 | const notificationConfig: Record< |
| 40 | NotificationType, |
| 41 | { |
| 42 | icon: React.ReactNode |
| 43 | containerClass: string |
| 44 | iconClass: string |
| 45 | } |
| 46 | > = { |
| 47 | success: { |
| 48 | icon: <CheckCircle2 className="w-5 h-5" />, |
| 49 | containerClass: 'border-green-200 dark:border-green-800/50 bg-green-50/95 dark:bg-green-950/95', |
| 50 | iconClass: 'text-green-600 dark:text-green-400', |
| 51 | }, |
| 52 | error: { |
| 53 | icon: <XCircle className="w-5 h-5" />, |
| 54 | containerClass: 'border-red-200 dark:border-red-800/50 bg-red-50/95 dark:bg-red-950/95', |
| 55 | iconClass: 'text-red-600 dark:text-red-400', |
| 56 | }, |
| 57 | warning: { |
| 58 | icon: <AlertCircle className="w-5 h-5" />, |
| 59 | containerClass: |
| 60 | 'border-yellow-200 dark:border-yellow-800/50 bg-yellow-50/95 dark:bg-yellow-950/95', |
| 61 | iconClass: 'text-yellow-600 dark:text-yellow-500', |
| 62 | }, |
| 63 | info: { |
| 64 | icon: <Info className="w-5 h-5" />, |
| 65 | containerClass: 'border-blue-200 dark:border-blue-800/50 bg-blue-50/95 dark:bg-blue-950/95', |
| 66 | iconClass: 'text-blue-600 dark:text-blue-400', |
| 67 | }, |
| 68 | loading: { |
| 69 | icon: <Loader2 className="w-5 h-5 animate-spin" />, |
| 70 | containerClass: 'border-border bg-card/95', |
| 71 | iconClass: 'text-primary', |
| 72 | }, |
| 73 | } |
| 74 | |
| 75 | export const NotificationCenter: React.FC = () => { |
| 76 | const [items, setItems] = useState<NotificationItem[]>([]) |
| 77 | const timeoutsRef = useRef<Record<string, number>>({}) |
| 78 | const remainingRef = useRef<Record<string, number>>({}) |
| 79 | const animationStartRef = useRef<Record<string, number>>({}) |
| 80 | |
| 81 | useEffect(() => { |
| 82 | function onAdd(e: Event) { |
| 83 | const detail = (e as CustomEvent)?.detail as NotificationDetail | undefined |
| 84 | if (!detail) |
| 85 | return |
| 86 | const uid = detail._uid || genUid() |
| 87 | const key = detail.key || detail.id |
| 88 | const type = detail.type || 'info' |
| 89 | // loading 类型默认不自动关闭,其他类型默认 3 秒 |
| 90 | const defaultDuration = type === 'loading' ? 0 : 3000 |
| 91 | const duration |
| 92 | = typeof detail.duration === 'number' ? detail.duration * 1000 : defaultDuration |
| 93 | const expiresAt = duration > 0 ? Date.now() + duration : 0 |
| 94 | const item: NotificationItem = { |
| 95 | uid, |
| 96 | key, |
| 97 | content: detail.content || '', |
| 98 | duration, |
| 99 | expiresAt, |
| 100 | visible: false, |
| 101 | type, |
| 102 | isPaused: false, |
| 103 | } |
| 104 | |
| 105 | // 相同 key 去重:如果已有同 key 的通知,替换内容并重置计时器,不再新增 |
| 106 | if (key) { |
| 107 | setItems((prev) => { |
| 108 | const existingIndex = prev.findIndex(it => it.key === key) |
| 109 | if (existingIndex !== -1) { |
| 110 | const existing = prev[existingIndex] |
| 111 | // 清除旧的定时器 |
| 112 | if (timeoutsRef.current[existing.uid]) { |
| 113 | clearTimeout(timeoutsRef.current[existing.uid]) |
| 114 | delete timeoutsRef.current[existing.uid] |
| 115 | } |
| 116 | delete remainingRef.current[existing.uid] |
| 117 | delete animationStartRef.current[existing.uid] |
| 118 | |
| 119 | // 用新 uid 替换旧通知,保持位置不变 |
| 120 | const updated = [...prev] |
| 121 | updated[existingIndex] = { ...item, visible: true } |
| 122 | return updated |
| 123 | } |
| 124 | return [item, ...prev] |
| 125 | }) |
| 126 | } |
| 127 | else { |
| 128 | // 无 key 的通知正常添加到顶部 |
| 129 | setItems(prev => [item, ...prev]) |
| 130 | } |
| 131 | |
| 132 | // 触发入场动画(仅对新增的通知) |
| 133 | setTimeout(() => { |
| 134 | setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: true } : it))) |
| 135 | }, 10) |
| 136 | |
| 137 | // 自动关闭(duration > 0,带 key 的通知也需要自动关闭) |
| 138 | if (duration > 0) { |
| 139 | // 记录动画开始时间 |
| 140 | animationStartRef.current[uid] = Date.now() |
| 141 | remainingRef.current[uid] = duration |
| 142 | |
| 143 | const timeoutId = window.setTimeout(() => { |
| 144 | // 开始退出动画 |
| 145 | setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: false } : it))) |
| 146 | // 动画结束后移除 |
| 147 | const removeId = window.setTimeout(() => { |
| 148 | setItems(prev => prev.filter(it => it.uid !== uid)) |
| 149 | delete timeoutsRef.current[uid] |
| 150 | delete remainingRef.current[uid] |
| 151 | delete animationStartRef.current[uid] |
| 152 | }, 300) |
| 153 | timeoutsRef.current[uid] = removeId |
| 154 | }, duration) |
| 155 | timeoutsRef.current[uid] = timeoutId |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | function onRemove(e: Event) { |
| 160 | const detail = (e as CustomEvent)?.detail as NotificationDetail | undefined |
| 161 | if (!detail) |
| 162 | return |
| 163 | const uid = detail._uid |
| 164 | const key = detail.key || detail.id |
| 165 | if (uid) { |
| 166 | if (timeoutsRef.current[uid]) { |
| 167 | clearTimeout(timeoutsRef.current[uid]) |
| 168 | delete timeoutsRef.current[uid] |
| 169 | } |
| 170 | setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: false } : it))) |
| 171 | setTimeout(() => { |
| 172 | setItems(prev => prev.filter(it => it.uid !== uid)) |
| 173 | delete remainingRef.current[uid] |
| 174 | delete animationStartRef.current[uid] |
| 175 | }, 300) |
| 176 | } |
| 177 | if (key) { |
| 178 | setItems(prev => prev.map(it => (it.key === key ? { ...it, visible: false } : it))) |
| 179 | setTimeout(() => { |
| 180 | setItems(prev => prev.filter((it) => { |
| 181 | if (it.key === key) { |
| 182 | if (timeoutsRef.current[it.uid]) { |
| 183 | clearTimeout(timeoutsRef.current[it.uid]) |
| 184 | delete timeoutsRef.current[it.uid] |
| 185 | } |
| 186 | delete remainingRef.current[it.uid] |
| 187 | delete animationStartRef.current[it.uid] |
| 188 | return false |
| 189 | } |
| 190 | return true |
| 191 | })) |
| 192 | }, 300) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | window.addEventListener('aito:notification', onAdd as EventListener) |
| 197 | window.addEventListener('aito:notification-remove', onRemove as EventListener) |
| 198 | return () => { |
| 199 | window.removeEventListener('aito:notification', onAdd as EventListener) |
| 200 | window.removeEventListener('aito:notification-remove', onRemove as EventListener) |
| 201 | } |
| 202 | }, []) |
| 203 | |
| 204 | // 关闭通知 |
| 205 | const handleClose = (uid: string) => { |
| 206 | if (timeoutsRef.current[uid]) { |
| 207 | clearTimeout(timeoutsRef.current[uid]) |
| 208 | delete timeoutsRef.current[uid] |
| 209 | } |
| 210 | setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: false } : it))) |
| 211 | setTimeout(() => { |
| 212 | setItems(prev => prev.filter(it => it.uid !== uid)) |
| 213 | delete remainingRef.current[uid] |
| 214 | delete animationStartRef.current[uid] |
| 215 | }, 300) |
| 216 | } |
| 217 | |
| 218 | // 鼠标进入暂停自动关闭 |
| 219 | const handleMouseEnter = (uid: string, item: NotificationItem) => { |
| 220 | // 清除当前定时器 |
| 221 | if (timeoutsRef.current[uid]) { |
| 222 | clearTimeout(timeoutsRef.current[uid]) |
| 223 | delete timeoutsRef.current[uid] |
| 224 | } |
| 225 | // 计算剩余时间 |
| 226 | const startTime = animationStartRef.current[uid] |
| 227 | if (startTime && item.duration > 0) { |
| 228 | const elapsed = Date.now() - startTime |
| 229 | const remaining = Math.max(0, item.duration - elapsed) |
| 230 | remainingRef.current[uid] = remaining |
| 231 | } |
| 232 | // 标记为暂停,触发 CSS 动画暂停 |
| 233 | setItems(prev => prev.map(it => (it.uid === uid ? { ...it, isPaused: true } : it))) |
| 234 | } |
| 235 | |
| 236 | // 鼠标离开恢复自动关闭 |
| 237 | const handleMouseLeave = (uid: string, item: NotificationItem) => { |
| 238 | const remaining = remainingRef.current[uid] |
| 239 | // 如果没有剩余时间或 duration 为 0,不需要恢复 |
| 240 | if (!remaining || remaining <= 0 || item.duration <= 0) { |
| 241 | setItems(prev => prev.map(it => (it.uid === uid ? { ...it, isPaused: false } : it))) |
| 242 | return |
| 243 | } |
| 244 | |
| 245 | // 更新动画开始时间,使进度条从暂停位置继续 |
| 246 | animationStartRef.current[uid] = Date.now() - (item.duration - remaining) |
| 247 | |
| 248 | // 恢复动画 |
| 249 | setItems(prev => prev.map(it => (it.uid === uid ? { ...it, isPaused: false } : it))) |
| 250 | |
| 251 | // 重新设置定时器 |
| 252 | const timeoutId = window.setTimeout(() => { |
| 253 | // 开始退出动画 |
| 254 | setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: false } : it))) |
| 255 | // 动画结束后移除 |
| 256 | const removeId = window.setTimeout(() => { |
| 257 | setItems(prev => prev.filter(it => it.uid !== uid)) |
| 258 | delete timeoutsRef.current[uid] |
| 259 | delete remainingRef.current[uid] |
| 260 | delete animationStartRef.current[uid] |
| 261 | }, 300) |
| 262 | timeoutsRef.current[uid] = removeId |
| 263 | }, remaining) |
| 264 | timeoutsRef.current[uid] = timeoutId |
| 265 | } |
| 266 | |
| 267 | return ( |
| 268 | <div className="fixed top-4 right-4 z-[500000] flex flex-col items-end gap-3 pointer-events-none"> |
| 269 | {items.map((item) => { |
| 270 | const config = notificationConfig[item.type] |
| 271 | |
| 272 | return ( |
| 273 | <div |
| 274 | key={item.uid} |
| 275 | className={cn( |
| 276 | 'w-[360px] max-w-[calc(100vw-2rem)] pointer-events-auto', |
| 277 | 'border rounded-lg shadow-lg backdrop-blur-sm', |
| 278 | 'transform transition-all duration-300 ease-out', |
| 279 | item.visible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-4', |
| 280 | config.containerClass, |
| 281 | )} |
| 282 | role="status" |
| 283 | aria-live="polite" |
| 284 | onClick={(e) => { |
| 285 | e.stopPropagation() |
| 286 | e.preventDefault() |
| 287 | }} |
| 288 | onMouseDown={(e) => { |
| 289 | e.stopPropagation() |
| 290 | e.preventDefault() |
| 291 | }} |
| 292 | onPointerDownCapture={(e) => { |
| 293 | // 在捕获阶段阻止事件传播 |
| 294 | // Radix UI 的 DismissableLayer 使用 onPointerDownCapture 检测外部点击 |
| 295 | // 必须在捕获阶段阻止,否则冒泡阶段已经太晚 |
| 296 | e.stopPropagation() |
| 297 | }} |
| 298 | onMouseEnter={() => handleMouseEnter(item.uid, item)} |
| 299 | onMouseLeave={() => handleMouseLeave(item.uid, item)} |
| 300 | > |
| 301 | <div className="flex items-start gap-3 p-4"> |
| 302 | {/* 图标 */} |
| 303 | <div className={cn('flex-shrink-0 mt-0.5', config.iconClass)}>{config.icon}</div> |
| 304 | |
| 305 | {/* 内容 */} |
| 306 | <div className="flex-1 min-w-0"> |
| 307 | <div className="text-sm font-medium text-foreground break-words"> |
| 308 | {item.content} |
| 309 | </div> |
| 310 | </div> |
| 311 | |
| 312 | {/* 关闭按钮 */} |
| 313 | <button |
| 314 | type="button" |
| 315 | aria-label="Close notification" |
| 316 | onClick={() => handleClose(item.uid)} |
| 317 | className={cn( |
| 318 | 'flex-shrink-0 p-1 rounded-md cursor-pointer', |
| 319 | 'text-muted-foreground/60 hover:text-foreground', |
| 320 | 'hover:bg-black/5 dark:hover:bg-white/10', |
| 321 | 'transition-colors duration-150', |
| 322 | )} |
| 323 | > |
| 324 | <X className="w-4 h-4" /> |
| 325 | </button> |
| 326 | </div> |
| 327 | |
| 328 | {/* 进度条(可选,显示剩余时间) */} |
| 329 | {item.duration > 0 && item.visible && ( |
| 330 | <div className="h-1 bg-black/5 dark:bg-white/5 rounded-b-lg overflow-hidden"> |
| 331 | <div |
| 332 | className={cn( |
| 333 | 'h-full', |
| 334 | item.type === 'success' && 'bg-green-500', |
| 335 | item.type === 'error' && 'bg-red-500', |
| 336 | item.type === 'warning' && 'bg-yellow-500', |
| 337 | item.type === 'info' && 'bg-blue-500', |
| 338 | item.type === 'loading' && 'bg-primary', |
| 339 | )} |
| 340 | style={{ |
| 341 | width: '100%', |
| 342 | animation: `shrink ${item.duration}ms linear forwards`, |
| 343 | animationPlayState: item.isPaused ? 'paused' : 'running', |
| 344 | }} |
| 345 | /> |
| 346 | </div> |
| 347 | )} |
| 348 | </div> |
| 349 | ) |
| 350 | })} |
| 351 | |
| 352 | {/* 进度条动画 CSS */} |
| 353 | <style jsx> |
| 354 | {` |
| 355 | @keyframes shrink { |
| 356 | from { |
| 357 | width: 100%; |
| 358 | } |
| 359 | to { |
| 360 | width: 0%; |
| 361 | } |
| 362 | } |
| 363 | `} |
| 364 | </style> |
| 365 | </div> |
| 366 | ) |
| 367 | } |
| 368 | |
| 369 | export default NotificationCenter |
| 370 |