Files
omniai-web/src/components/NotificationCenter.tsx
T

167 lines
6.1 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
import {
BellOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
DislikeOutlined,
ExclamationCircleOutlined,
LikeOutlined,
LockOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState } from "react";
import type { WebNotification, WebNotificationType, WebViewKey } from "../types";
import { AnimatedPanel } from "./AnimatedPanel";
2026-06-02 12:38:01 +08:00
const NOTIFICATION_ICONS: Record<WebNotificationType, React.ReactNode> = {
task_completed: <CheckCircleOutlined style={{ color: "#10b981" }} />,
task_failed: <CloseCircleOutlined style={{ color: "#ef4444" }} />,
review_pending: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
review_passed: <LikeOutlined style={{ color: "#10b981" }} />,
review_rejected: <DislikeOutlined style={{ color: "#f59e0b" }} />,
credits_low: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
session_expired: <LockOutlined style={{ color: "#ef4444" }} />,
info: <BellOutlined style={{ color: "#2563eb" }} />,
};
function parseTimestamp(dateStr: string): number {
if (!dateStr) return Date.now();
let ts = new Date(dateStr).getTime();
if (Number.isNaN(ts)) {
ts = new Date(dateStr.replace(" ", "T") + "Z").getTime();
}
if (Number.isNaN(ts)) return Date.now();
return ts;
}
function timeAgo(dateStr: string, now: number): string {
const ts = parseTimestamp(dateStr);
const diff = now - ts;
if (diff < 0) return "刚刚";
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "刚刚";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}天前`;
const date = new Date(ts);
return `${date.getMonth() + 1}${date.getDate()}`;
}
interface NotificationCenterProps {
items?: WebNotification[];
onNavigate: (view: WebViewKey, targetId?: string) => void;
onMarkRead?: (id: string, isRead?: boolean) => void;
onMarkAllRead?: () => void;
onClear?: () => void;
}
function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onClear }: NotificationCenterProps) {
const [readIds, setReadIds] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [now, setNow] = useState(Date.now);
const containerRef = useRef<HTMLDivElement>(null);
const notifications = items ?? [];
const unreadCount = notifications.filter((n) => !readIds.includes(n.id) && !n.isRead).length;
useEffect(() => {
if (!open) return;
const timer = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(timer);
}, [open]);
useEffect(() => {
if (items && items.length === 0) {
setReadIds([]);
}
}, [items]);
useEffect(() => {
if (!open) return;
const handlePointerDown = (e: PointerEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
const markAllRead = () => {
setReadIds((prev) => Array.from(new Set([...prev, ...notifications.map((n) => n.id)])));
onMarkAllRead?.();
};
const handleClickNotification = (n: WebNotification) => {
setReadIds((prev) => (prev.includes(n.id) ? prev : [...prev, n.id]));
onMarkRead?.(n.id, true);
setOpen(false);
if (n.targetView) {
onNavigate(n.targetView, n.targetId);
}
};
return (
<div className="notification-center" ref={containerRef}>
<button
className="notification-center__bell"
type="button"
title="通知中心"
aria-label={`通知中心${unreadCount > 0 ? `${unreadCount}条未读` : ""}`}
onClick={() => { setOpen((v) => !v); setNow(Date.now()); }}
>
<BellOutlined />
{unreadCount > 0 && (
<span className="notification-center__badge">{unreadCount > 99 ? "99+" : unreadCount}</span>
)}
</button>
<AnimatedPanel open={open} className="notification-center__panel" exitDuration={140}>
2026-06-02 12:38:01 +08:00
<div className="notification-center__header">
<span className="notification-center__title"></span>
<div className="notification-center__header-actions">
{unreadCount > 0 && (
<button className="notification-center__mark-read" type="button" onClick={markAllRead}>
</button>
)}
{notifications.length > 0 && onClear && (
<button className="notification-center__clear" type="button" onClick={() => { onClear(); setOpen(false); }}>
<DeleteOutlined />
</button>
)}
</div>
</div>
<div className="notification-center__list">
{notifications.length === 0 ? (
<div className="notification-center__empty">
<BellOutlined style={{ fontSize: 28, opacity: 0.3 }} />
<span></span>
</div>
) : (
notifications.map((n) => (
<button
key={n.id}
type="button"
className={`notification-center__item${n.isRead || readIds.includes(n.id) ? "" : " is-unread"}`}
onClick={() => handleClickNotification(n)}
>
<span className="notification-center__item-icon">
{NOTIFICATION_ICONS[n.type]}
</span>
<div className="notification-center__item-body">
<span className="notification-center__item-title">{n.title}</span>
<span className="notification-center__item-desc">{n.description}</span>
</div>
<span className="notification-center__item-time">{timeAgo(n.createdAt, now)}</span>
</button>
))
)}
</div>
</AnimatedPanel>
2026-06-02 12:38:01 +08:00
</div>
);
}
export default NotificationCenter;