Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user