6b9953625e
- 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>
167 lines
6.1 KiB
TypeScript
167 lines
6.1 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";
|
||
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;
|