Files
omniai-web/src/components/NotificationCenter.tsx
T
stringadmin 6b9953625e feat: UI interaction polish — exit animations, hover effects, directional transitions
- Add AnimatedPanel component with CSS transition-based enter/exit for
  Profile popover and Notification panel (140ms scale+fade)
- Add nav-activate-pulse animation for floating-nav active indicator (320ms glow)
- Add tool-panel-fade-in crossfade when switching ecommerce tools
- Add carousel-card-label slide-up-in 260ms on active carousel card
- Add feature-visual img hover scale(1.03)+brightness, experience-route hover translateY(-2px)
- Add community-case-card--mosaic hover scale(1.02)+shadow lift
- Add directional PageTransition: forward→slideX(20px), backward→slideX(-20px)
- Move vite proxy target from hardcoded IP to VITE_DEV_PROXY env variable
- Add .env.example for developer onboarding

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:31:39 +08:00

167 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
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}>
<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>
</div>
);
}
export default NotificationCenter;