168 lines
6.0 KiB
TypeScript
168 lines
6.0 KiB
TypeScript
|
|
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";
|
|||
|
|
|
|||
|
|
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>
|
|||
|
|
{open && (
|
|||
|
|
<div className="notification-center__panel">
|
|||
|
|
<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>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default NotificationCenter;
|