casualroom/apps/fenghuo/web/app/[locale]/dashboard/notification/page.tsx

598 lines
32 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'use client';
import { useState, useEffect } from 'react';
import { PageInfo, SiteHeader } from "@/components/site-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nice/ui/components/card';
import { Badge } from '@nice/ui/components/badge';
import { Button } from '@nice/ui/components/button';
import { Checkbox } from '@nice/ui/components/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@nice/ui/components/select';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@nice/ui/components/dropdown-menu';
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PaginationEllipsis } from '@nice/ui/components/pagination';
import {
IconBell,
IconBellRinging,
IconHeart,
IconFileText,
IconShare,
IconSettings,
IconClock,
IconCheck,
IconChecks,
IconTrash,
IconFilter,
IconDots,
IconX
} from '@tabler/icons-react';
import { useSetPageInfo } from '@/components/providers/dashboard-provider';
// 通知类型定义
type NotificationType = 'like' | 'comment' | 'follow' | 'system' | 'article' | 'security';
interface Notification {
id: string;
type: NotificationType;
title: string;
message: string;
time: string;
isRead: boolean;
isSelected?: boolean;
data?: {
url?: string;
articleId?: string;
userId?: string;
};
}
// 模拟通知数据
const mockNotifications: Notification[] = [
{
id: '1',
type: 'like',
title: '文章获得点赞',
message: '您的文章《Next.js 最佳实践》收到了新的点赞',
time: '5分钟前',
isRead: false,
data: { articleId: '123' }
},
{
id: '2',
type: 'comment',
title: '新评论',
message: '用户李四评论了您的文章《TypeScript 进阶指南》:这篇文章写得非常详细,帮助很大!',
time: '1小时前',
isRead: false,
data: { articleId: '124', userId: 'user_123' }
},
{
id: '3',
type: 'follow',
title: '新关注者',
message: '用户王五关注了您,快去看看他的主页吧',
time: '2小时前',
isRead: true,
data: { userId: 'user_124' }
},
{
id: '4',
type: 'system',
title: '存储空间提醒',
message: '您的存储空间使用已达到60%,建议及时清理不必要的文件',
time: '6小时前',
isRead: false
},
{
id: '5',
type: 'article',
title: '文章发布成功',
message: '您的文章《React Hooks 深度解析》已成功发布',
time: '1天前',
isRead: true,
data: { articleId: '125' }
},
{
id: '6',
type: 'security',
title: '登录提醒',
message: '检测到您的账号在新设备上登录,如非本人操作请及时修改密码',
time: '2天前',
isRead: false
},
// 更多模拟数据...
...Array.from({ length: 20 }, (_, i) => ({
id: `${i + 7}`,
type: ['like', 'comment', 'follow', 'system'][i % 4] as NotificationType,
title: `通知标题 ${i + 7}`,
message: `这是第 ${i + 7} 条通知的详细内容`,
time: `${i + 3}天前`,
isRead: Math.random() > 0.5
}))
];
useSetPageInfo({
title: '通知中心',
subtitle: '查看系统通知、消息以及系统更新等',
})
// 获取通知类型的图标
function getNotificationIcon(type: NotificationType, isRead: boolean) {
const iconClass = `h-5 w-5 ${isRead ? 'opacity-60' : ''}`;
switch (type) {
case 'like':
return <IconHeart className={`${iconClass} text-red-500`} />;
case 'comment':
return <IconFileText className={`${iconClass} text-blue-500`} />;
case 'follow':
return <IconShare className={`${iconClass} text-green-500`} />;
case 'system':
return <IconSettings className={`${iconClass} text-orange-500`} />;
case 'article':
return <IconFileText className={`${iconClass} text-purple-500`} />;
case 'security':
return <IconBellRinging className={`${iconClass} text-red-600`} />;
default:
return <IconBell className={iconClass} />;
}
}
// 获取通知类型的标签
function getNotificationTypeLabel(type: NotificationType) {
const typeMap: Record<NotificationType, string> = {
like: '点赞',
comment: '评论',
follow: '关注',
system: '系统',
article: '文章',
security: '安全'
};
return typeMap[type];
}
// 获取通知类型的颜色
function getNotificationTypeBadgeVariant(type: NotificationType) {
const variantMap: Record<NotificationType, 'default' | 'secondary' | 'destructive' | 'outline'> = {
like: 'default',
comment: 'secondary',
follow: 'outline',
system: 'secondary',
article: 'outline',
security: 'destructive'
};
return variantMap[type];
}
export default function NotificationPage() {
const [notifications, setNotifications] = useState<Notification[]>(mockNotifications);
const [selectedNotifications, setSelectedNotifications] = useState<Set<string>>(new Set());
const [filterType, setFilterType] = useState<'all' | NotificationType>('all');
const [filterRead, setFilterRead] = useState<'all' | 'read' | 'unread'>('all');
const [currentPage, setCurrentPage] = useState(1);
const [isAllSelected, setIsAllSelected] = useState(false);
const pageSize = 7;
// 当筛选条件改变时重置选择状态
useEffect(() => {
setSelectedNotifications(new Set());
setIsAllSelected(false);
setCurrentPage(1);
}, [filterType, filterRead]);
// 过滤通知
const filteredNotifications = notifications.filter(notification => {
const typeMatch = filterType === 'all' || notification.type === filterType;
const readMatch = filterRead === 'all' ||
(filterRead === 'read' && notification.isRead) ||
(filterRead === 'unread' && !notification.isRead);
return typeMatch && readMatch;
});
// 分页
const totalPages = Math.ceil(filteredNotifications.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const currentNotifications = filteredNotifications.slice(startIndex, startIndex + pageSize);
// 统计数据
const unreadCount = notifications.filter(n => !n.isRead).length;
const selectedCount = isAllSelected ? filteredNotifications.length : selectedNotifications.size;
// 批量操作
const handleSelectAll = () => {
if (isAllSelected || selectedCount === filteredNotifications.length) {
setSelectedNotifications(new Set());
setIsAllSelected(false);
} else {
setSelectedNotifications(new Set(filteredNotifications.map(n => n.id)));
setIsAllSelected(true);
}
};
const handleSelectNotification = (id: string) => {
const newSelected = new Set(selectedNotifications);
if (newSelected.has(id)) {
newSelected.delete(id);
setIsAllSelected(false);
} else {
newSelected.add(id);
if (newSelected.size === filteredNotifications.length) {
setIsAllSelected(true);
}
}
setSelectedNotifications(newSelected);
};
const handleMarkAsRead = (ids: string[]) => {
const targetIds = isAllSelected ? filteredNotifications.map(n => n.id) : ids;
setNotifications(prev => prev.map(n =>
targetIds.includes(n.id) ? { ...n, isRead: true } : n
));
setSelectedNotifications(new Set());
setIsAllSelected(false);
};
const handleMarkAsUnread = (ids: string[]) => {
const targetIds = isAllSelected ? filteredNotifications.map(n => n.id) : ids;
setNotifications(prev => prev.map(n =>
targetIds.includes(n.id) ? { ...n, isRead: false } : n
));
setSelectedNotifications(new Set());
setIsAllSelected(false);
};
const handleDelete = (ids: string[]) => {
const targetIds = isAllSelected ? filteredNotifications.map(n => n.id) : ids;
setNotifications(prev => prev.filter(n => !targetIds.includes(n.id)));
setSelectedNotifications(new Set());
setIsAllSelected(false);
};
const handleNotificationClick = (notification: Notification) => {
// 标记为已读
if (!notification.isRead) {
handleMarkAsRead([notification.id]);
}
console.log('Navigate to:', notification.data);
};
return (
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-6 p-6">
{/* 左右布局容器 */}
<div className="grid grid-cols-1 lg:grid-cols-6 gap-6">
{/* 左侧:通知统计卡片 */}
<div className="lg:col-span-1">
<div className="space-y-4">
{/* 通知统计卡片 */}
<Card className="hover:shadow-md transition-shadow">
<CardHeader className='mt-1'>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-7">
{/* 总通知 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<IconBell className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-xl font-bold">{notifications.length}</p>
</div>
</div>
</div>
{/* 未读通知 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<IconBellRinging className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-xl font-bold">{unreadCount}</p>
</div>
</div>
</div>
{/* 已读通知 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<IconCheck className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-xl font-bold">{notifications.length - unreadCount}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* 右侧:通知列表 */}
<div className="lg:col-span-5">
<Card className='py-6'>
<CardHeader className="pb-3">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<CardTitle className="text-lg"></CardTitle>
{/* 筛选器 */}
<div className="flex items-center gap-2">
<IconFilter className="h-4 w-4 text-muted-foreground" />
<Select value={filterType} onValueChange={(value: any) => setFilterType(value)}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="通知类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="like"></SelectItem>
<SelectItem value="comment"></SelectItem>
<SelectItem value="follow"></SelectItem>
<SelectItem value="system"></SelectItem>
<SelectItem value="article"></SelectItem>
<SelectItem value="security"></SelectItem>
</SelectContent>
</Select>
<Select value={filterRead} onValueChange={(value: any) => setFilterRead(value)}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="阅读状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="unread"></SelectItem>
<SelectItem value="read"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* 批量操作工具栏 */}
{selectedCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-md">
<span className="text-sm text-muted-foreground">
{selectedCount}
</span>
<div className="h-4 w-px bg-border" />
<Button
size="sm"
variant="ghost"
onClick={() => handleMarkAsRead(isAllSelected ? filteredNotifications.map(n => n.id) : Array.from(selectedNotifications))}
className="h-7 px-2"
>
<IconCheck className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleMarkAsUnread(isAllSelected ? filteredNotifications.map(n => n.id) : Array.from(selectedNotifications))}
className="h-7 px-2"
>
<IconX className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(isAllSelected ? filteredNotifications.map(n => n.id) : Array.from(selectedNotifications))}
className="h-7 px-2 text-destructive hover:text-destructive"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSelectAll}
>
<IconChecks className="h-4 w-4" />
{isAllSelected ? '取消全选' : '选择全部'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleMarkAsRead(notifications.filter(n => !n.isRead).map(n => n.id))}
disabled={unreadCount === 0}
>
<IconCheck className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{currentNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<IconBell className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground"></p>
</div>
) : (
<div className="divide-y">
{currentNotifications.map((notification) => (
<div
key={notification.id}
className={`flex items-start gap-2 p-3 hover:bg-muted/50 cursor-pointer transition-colors ${!notification.isRead ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''
}`}
onClick={() => handleNotificationClick(notification)}
>
{/* 选择框 */}
<Checkbox
checked={isAllSelected || selectedNotifications.has(notification.id)}
onCheckedChange={() => handleSelectNotification(notification.id)}
onClick={(e) => e.stopPropagation()}
className="mt-1"
/>
{/* 通知图标 */}
<div className="flex-shrink-0 mt-0.5">
{getNotificationIcon(notification.type, notification.isRead)}
</div>
{/* 通知内容 */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<h4 className={`text-sm font-medium truncate ${notification.isRead ? 'text-muted-foreground' : 'text-foreground'
}`}>
{notification.title}
</h4>
<Badge variant={getNotificationTypeBadgeVariant(notification.type)} className="text-xs">
{getNotificationTypeLabel(notification.type)}
</Badge>
{!notification.isRead && (
<div className="w-2 h-2 bg-blue-500 rounded-full" />
)}
</div>
<p className={`text-sm line-clamp-2 ${notification.isRead ? 'text-muted-foreground' : 'text-foreground/80'
}`}>
{notification.message}
</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<IconClock className="h-3 w-3" />
{notification.time}
</div>
</div>
{/* 操作菜单 */}
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead([notification.id]);
}}
disabled={notification.isRead}
>
<IconCheck className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleMarkAsUnread([notification.id]);
}}
disabled={!notification.isRead}
>
<IconX className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete([notification.id]);
}}
variant="destructive"
>
<IconTrash className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex justify-center mt-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage > 1) setCurrentPage(currentPage - 1);
}}
aria-disabled={currentPage === 1}
className={currentPage === 1 ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{/* 页码 */}
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
let pageNum;
if (totalPages <= 7) {
pageNum = i + 1;
} else if (currentPage <= 4) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 3) {
pageNum = totalPages - 6 + i;
} else {
pageNum = currentPage - 3 + i;
}
if (pageNum === currentPage - 2 && currentPage > 4 && totalPages > 7) {
return (
<PaginationItem key="ellipsis-1">
<PaginationEllipsis />
</PaginationItem>
);
}
if (pageNum === currentPage + 2 && currentPage < totalPages - 3 && totalPages > 7) {
return (
<PaginationItem key="ellipsis-2">
<PaginationEllipsis />
</PaginationItem>
);
}
return (
<PaginationItem key={pageNum}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setCurrentPage(pageNum);
}}
isActive={pageNum === currentPage}
>
{pageNum}
</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
}}
aria-disabled={currentPage === totalPages}
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
</div>
</div>
</div>
);
}