import { useCallback, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import { Bell, TrendingUp, TrendingDown, X } from 'lucide-react' import type { AlertEvent } from '@/lib/api' import { fmtPct, fmtPrice } from '@/lib/format' import { cn } from '@/lib/cn' import { playNotificationSound } from '@/lib/notificationSound' // ===== 全局状态 (模块级, 仿 Toast.tsx 模式) ===== type Item = { id: number; alert: AlertEvent } let _id = 0 let _queue: Item[] = [] const AUTO_DISMISS = 5000 // 5 秒自动消失 const _listeners: Set<(items: Item[]) => void> = new Set() /** 从 localStorage 读取配置 */ function getEnabled(): boolean { try { const v = localStorage.getItem('alert_toast_enabled') return v === null ? true : v === '1' // 默认开启 } catch { return true } } function getMaxVisible(): number { try { const v = parseInt(localStorage.getItem('alert_toast_max') || '', 10) return v >= 1 && v <= 10 ? v : 3 // 默认 3, 范围 1-10 } catch { return 3 } } /** 通知外部配置变更后刷新 (设置页改了配置后调用) */ export function refreshAlertToastConfig() { _emit() } function _emit() { _listeners.forEach(fn => fn([..._queue])) } /** 推入单条监控告警通知 (兼容入口, 不发声 — 发声由批量入口统一处理) */ export function pushAlertToast(alert: AlertEvent) { pushAlertToasts([alert]) } /** * 批量推入监控告警通知 (一轮 SSE 多只新命中时调用)。 * - 每条都弹 Toast (受 maxVisible 上限, 超出丢最旧) * - 整批只播放一声通知音, 避免短时连续响多声刷屏 */ export function pushAlertToasts(alerts: AlertEvent[]) { if (alerts.length === 0) return if (!getEnabled()) return // 开关关闭: 不弹 const maxVisible = getMaxVisible() const newItems = alerts.map(alert => ({ id: ++_id, alert })) _queue = [..._queue, ...newItems] // 超出上限: 丢弃最旧的 if (_queue.length > maxVisible) { _queue = _queue.slice(-maxVisible) } _emit() for (const item of newItems) { setTimeout(() => dismiss(item.id), AUTO_DISMISS) } playNotificationSound() // 整批只响一声 } /** 手动关闭 */ export function dismiss(id: number) { _queue = _queue.filter(t => t.id !== id) _emit() } // ===== 配色 ===== const SEVERITY_BAR: Record = { info: 'bg-accent', warn: 'bg-warning', critical: 'bg-danger', } const SOURCE_BADGE: Record = { strategy: { label: '策略', cls: 'bg-amber-400/15 text-amber-400' }, signal: { label: '信号', cls: 'bg-accent/15 text-accent' }, price: { label: '价格', cls: 'bg-emerald-400/15 text-emerald-400' }, market: { label: '异动', cls: 'bg-purple-500/15 text-purple-400' }, new_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' }, dropped: { label: '移出', cls: 'bg-danger/15 text-danger' }, } // ===== 容器 — 挂在 Layout ===== export function AlertToastContainer() { const [items, setItems] = useState([]) const navigate = useNavigate() const sub = useCallback(() => { _listeners.add(setItems) return () => { _listeners.delete(setItems) } }, []) useEffect(sub, [sub]) // 点击通知 → 跳转监控中心 + 关闭当前通知 const handleClick = (id: number) => { dismiss(id) navigate('/monitor') } if (!items.length) return null return (
{items .filter(item => !(item.alert.source === 'strategy' && !item.alert.symbol)) .map(item => { const ev = item.alert const sev = SEVERITY_BAR[ev.severity ?? 'info'] ?? SEVERITY_BAR.info const badgeKey = (ev.source === 'strategy' && ev.type) ? ev.type : ev.source const badge = SOURCE_BADGE[badgeKey] ?? { label: badgeKey, cls: 'bg-elevated text-muted' } const pct = ev.change_pct ?? 0 const isStrategy = ev.source === 'strategy' const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null const sname = sm ? sm[1] : '' const isNew = ev.type === 'new_entry' return ( handleClick(item.id)} className="pointer-events-auto relative overflow-hidden rounded-xl border border-border/60 bg-surface/95 backdrop-blur-md shadow-2xl pl-3 pr-2 py-2.5 cursor-pointer hover:border-accent/40 hover:shadow-accent/10 transition-all" > {/* 左侧色条 */}
{/* 顶行: 分类标签 + 代码/名称 + 涨跌幅 + 关闭 */}
{badge.label} {ev.symbol && {ev.symbol}} {ev.name && {ev.name}} {ev.change_pct != null && ( = 0 ? 'text-danger' : 'text-bear')}> {pct >= 0 ? : } {fmtPct(pct)} )}
{/* 底行: 策略类型走新格式, 其他走旧格式 */} {isStrategy ? (
{isNew ? '进入' : '移出'} 策略 「{sname}」 {ev.price != null && {fmtPrice(ev.price)}}
) : (
{ev.message && {ev.message}} {ev.price != null && {fmtPrice(ev.price)}}
)} ) })}
) }