191 lines
7.5 KiB
TypeScript
191 lines
7.5 KiB
TypeScript
|
|
import { useState, useEffect } from "react";
|
||
|
|
import axios from "axios";
|
||
|
|
import { Link, router, usePage } from "@inertiajs/react";
|
||
|
|
import { Bell, CheckCheck } from "lucide-react";
|
||
|
|
import {
|
||
|
|
DropdownMenu,
|
||
|
|
DropdownMenuContent,
|
||
|
|
DropdownMenuSeparator,
|
||
|
|
DropdownMenuTrigger,
|
||
|
|
} from "@/Components/ui/dropdown-menu";
|
||
|
|
import { Button } from "@/Components/ui/button";
|
||
|
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||
|
|
import { formatDate } from "@/lib/date";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
interface NotificationData {
|
||
|
|
message: string;
|
||
|
|
link?: string;
|
||
|
|
action?: string;
|
||
|
|
[key: string]: any;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Notification {
|
||
|
|
id: string;
|
||
|
|
type: string;
|
||
|
|
data: NotificationData;
|
||
|
|
read_at: string | null;
|
||
|
|
created_at: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface NotificationsProp {
|
||
|
|
latest: Notification[];
|
||
|
|
unread_count: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function NotificationDropdown() {
|
||
|
|
const { notifications } = usePage<{ notifications?: NotificationsProp }>().props;
|
||
|
|
|
||
|
|
if (!notifications) return null;
|
||
|
|
|
||
|
|
// 使用整體的 notifications 物件作為初始狀態,方便後續更新
|
||
|
|
const [data, setData] = useState<NotificationsProp>(notifications);
|
||
|
|
const { latest, unread_count } = data;
|
||
|
|
const [isOpen, setIsOpen] = useState(false);
|
||
|
|
|
||
|
|
// 輪詢機制
|
||
|
|
useEffect(() => {
|
||
|
|
const intervalId = setInterval(() => {
|
||
|
|
axios.get(route('notifications.check'))
|
||
|
|
.then(response => {
|
||
|
|
setData(response.data);
|
||
|
|
})
|
||
|
|
.catch(error => {
|
||
|
|
console.error("Failed to fetch notifications:", error);
|
||
|
|
});
|
||
|
|
}, 30000); // 30 秒
|
||
|
|
|
||
|
|
return () => clearInterval(intervalId);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 當 Inertia props 更新時(例如頁面跳轉),同步更新本地狀態
|
||
|
|
useEffect(() => {
|
||
|
|
if (notifications) {
|
||
|
|
setData(notifications);
|
||
|
|
}
|
||
|
|
}, [notifications]);
|
||
|
|
|
||
|
|
const handleMarkAllAsRead = (e: React.MouseEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
|
||
|
|
// 樂觀更新 (Optimistic Update)
|
||
|
|
setData(prev => ({
|
||
|
|
...prev,
|
||
|
|
unread_count: 0,
|
||
|
|
latest: prev.latest.map(n => ({ ...n, read_at: new Date().toISOString() }))
|
||
|
|
}));
|
||
|
|
|
||
|
|
router.post(route('notifications.read-all'), {}, {
|
||
|
|
preserveScroll: true,
|
||
|
|
preserveState: true,
|
||
|
|
onSuccess: () => {
|
||
|
|
// 成功後重新整理一次確保數據正確 (可選)
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleNotificationClick = (notification: Notification) => {
|
||
|
|
if (!notification.read_at) {
|
||
|
|
// 樂觀更新
|
||
|
|
setData(prev => ({
|
||
|
|
...prev,
|
||
|
|
unread_count: Math.max(0, prev.unread_count - 1),
|
||
|
|
latest: prev.latest.map(n =>
|
||
|
|
n.id === notification.id
|
||
|
|
? { ...n, read_at: new Date().toISOString() }
|
||
|
|
: n
|
||
|
|
)
|
||
|
|
}));
|
||
|
|
router.post(route('notifications.read', { id: notification.id }));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (notification.data.link) {
|
||
|
|
router.visit(notification.data.link);
|
||
|
|
}
|
||
|
|
|
||
|
|
setIsOpen(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen} modal={false}>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<Button variant="ghost" size="icon" className="relative text-slate-500 hover:text-slate-700 hover:bg-slate-100">
|
||
|
|
<Bell className="h-5 w-5" />
|
||
|
|
{unread_count > 0 && (
|
||
|
|
<span className="absolute top-1.5 right-1.5 flex h-2.5 w-2.5">
|
||
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||
|
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500"></span>
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent align="end" className="w-80 p-0 z-[100]" sideOffset={8}>
|
||
|
|
<div className="flex items-center justify-between p-4 pb-2">
|
||
|
|
<h4 className="font-semibold text-sm">通知中心</h4>
|
||
|
|
{unread_count > 0 && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
className="h-auto px-2 py-1 text-xs text-primary-main hover:text-primary-dark"
|
||
|
|
onClick={handleMarkAllAsRead}
|
||
|
|
>
|
||
|
|
<CheckCheck className="mr-1 h-3 w-3" />
|
||
|
|
全部已讀
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<DropdownMenuSeparator />
|
||
|
|
<ScrollArea className="h-[300px]">
|
||
|
|
{latest.length === 0 ? (
|
||
|
|
<div className="flex flex-col items-center justify-center py-8 text-slate-500">
|
||
|
|
<Bell className="h-8 w-8 mb-2 opacity-20" />
|
||
|
|
<p className="text-sm">目前沒有新通知</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="flex flex-col">
|
||
|
|
{latest.map((notification) => (
|
||
|
|
<button
|
||
|
|
key={notification.id}
|
||
|
|
className={cn(
|
||
|
|
"w-full text-left px-4 py-3 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0",
|
||
|
|
!notification.read_at && "bg-blue-50/50"
|
||
|
|
)}
|
||
|
|
onClick={() => handleNotificationClick(notification)}
|
||
|
|
>
|
||
|
|
<div className="flex items-start gap-3">
|
||
|
|
<div className={cn(
|
||
|
|
"mt-1 h-2 w-2 rounded-full flex-shrink-0",
|
||
|
|
!notification.read_at ? "bg-primary-main" : "bg-slate-200"
|
||
|
|
)} />
|
||
|
|
<div className="flex-1 space-y-1">
|
||
|
|
<p className={cn(
|
||
|
|
"text-sm leading-tight",
|
||
|
|
!notification.read_at ? "font-medium text-slate-900" : "text-slate-600"
|
||
|
|
)}>
|
||
|
|
{notification.data.message}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-slate-400">
|
||
|
|
{formatDate(notification.created_at)}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</ScrollArea>
|
||
|
|
<DropdownMenuSeparator />
|
||
|
|
<div className="p-2 text-center">
|
||
|
|
<Link
|
||
|
|
href="#"
|
||
|
|
className="text-xs text-slate-500 hover:text-primary-main transition-colors"
|
||
|
|
>
|
||
|
|
查看所有通知
|
||
|
|
</Link>
|
||
|
|
</div>
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
);
|
||
|
|
}
|