feat(notification): 實作通知輪詢與優化顯示名稱

- 新增通知輪詢 API 與前端自動更新機制
- 修正生產工單單號格式為 PRO-YYYYMMDD-XX
- 確保通知顯示實際建立者名稱而非系統
This commit is contained in:
2026-02-12 17:13:09 +08:00
parent 299602d3b1
commit 882091ce5f
14 changed files with 528 additions and 47 deletions

View File

@@ -0,0 +1,190 @@
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>
);
}

View File

@@ -44,6 +44,7 @@ import { usePermission } from "@/hooks/usePermission";
import ApplicationLogo from "@/Components/ApplicationLogo";
import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils";
import { PageProps } from "@/types/global";
import NotificationDropdown from "@/Components/Header/NotificationDropdown";
interface MenuItem {
id: string;
@@ -491,47 +492,51 @@ export default function AuthenticatedLayout({
</div>
{/* User Menu */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
<div className="hidden md:flex flex-col items-end mr-1">
<span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
{user.name} ({user.username})
</span>
<span className="text-xs text-slate-500">
{user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
</span>
</div>
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
<User className="h-5 w-5" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('profile.edit')}
preserveScroll={true}
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
>
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
<span>使</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('logout')}
method="post"
as="button"
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<NotificationDropdown />
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
<div className="hidden md:flex flex-col items-end mr-1">
<span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
{user.name} ({user.username})
</span>
<span className="text-xs text-slate-500">
{user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
</span>
</div>
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
<User className="h-5 w-5" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('profile.edit')}
preserveScroll={true}
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
>
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
<span>使</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('logout')}
method="post"
as="button"
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
{/* Sidebar Desktop */}