mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 09:14:11 +05:30
Initial commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import type { UserNotification } from "@prisma/client";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
import { NotificationActions } from "../notification-actions";
|
||||
import {
|
||||
getNotificationLevelTone,
|
||||
getNotificationReadLabel,
|
||||
getNotificationReadTone,
|
||||
notificationLevelLabels,
|
||||
} from "../notifications-calculations";
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: UserNotification;
|
||||
}
|
||||
|
||||
export function NotificationItem({ notification }: NotificationItemProps) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"rounded-2xl border border-border/50 bg-muted/25 p-4 transition-colors hover:bg-muted/45",
|
||||
!notification.isRead && "border-primary/20 bg-primary/[0.04]",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium tracking-tight text-foreground">{notification.title}</h3>
|
||||
<StatusBadge tone={getNotificationReadTone(notification.isRead)}>
|
||||
{getNotificationReadLabel(notification.isRead)}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={getNotificationLevelTone(notification.level)}>
|
||||
{notificationLevelLabels[notification.level]}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-muted-foreground">
|
||||
{notification.body}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<time dateTime={notification.createdAt.toISOString()}>
|
||||
{formatDate(notification.createdAt)}
|
||||
</time>
|
||||
{notification.link && (
|
||||
<Link href={notification.link} className="font-medium text-primary hover:underline">
|
||||
前往查看
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<NotificationActions notificationId={notification.id} isRead={notification.isRead} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from "next/link";
|
||||
import type { UserNotification } from "@prisma/client";
|
||||
import { Bell } from "lucide-react";
|
||||
import { EmptyState } from "@/components/shared/page-shell";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
|
||||
interface NotificationListProps {
|
||||
notifications: UserNotification[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export function NotificationList({ notifications, unreadCount }: NotificationListProps) {
|
||||
return (
|
||||
<Card className="rounded-2xl">
|
||||
<CardHeader className="border-b border-border/50">
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||
全部消息
|
||||
<span className="text-sm font-normal text-muted-foreground">未读 {unreadCount}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notifications.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Bell className="size-5" />}
|
||||
title="现在很安静"
|
||||
description="支付结果、订阅状态和系统提醒会集中出现在这里。"
|
||||
action={
|
||||
<Link href="/store" className={buttonVariants({ variant: "outline" })}>
|
||||
浏览套餐
|
||||
</Link>
|
||||
}
|
||||
className="border-0 bg-transparent py-10"
|
||||
/>
|
||||
) : (
|
||||
<div role="list" className="space-y-3">
|
||||
{notifications.map((notification) => (
|
||||
<div key={notification.id} role="listitem">
|
||||
<NotificationItem notification={notification} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
104
src/app/(user)/notifications/notification-actions.tsx
Normal file
104
src/app/(user)/notifications/notification-actions.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
markEveryNotificationAsRead,
|
||||
markNotificationAsRead,
|
||||
removeNotification,
|
||||
removeReadNotifications,
|
||||
} from "@/actions/user/notifications";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function NotificationActions({
|
||||
notificationId,
|
||||
isRead,
|
||||
}: {
|
||||
notificationId: string;
|
||||
isRead: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap gap-2 md:justify-end">
|
||||
{!isRead && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await markNotificationAsRead(notificationId);
|
||||
toast.success("已标记为已读");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "操作失败"));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
标记已读
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await removeNotification(notificationId);
|
||||
toast.success("通知已删除");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "删除失败"));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationBulkAction({
|
||||
unreadCount,
|
||||
readCount,
|
||||
}: {
|
||||
unreadCount: number;
|
||||
readCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await markEveryNotificationAsRead();
|
||||
toast.success("全部消息已标记为已读");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "操作失败"));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
全部标记已读
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{readCount > 0 && (
|
||||
<ConfirmActionButton
|
||||
variant="ghost"
|
||||
title="清空已读消息?"
|
||||
description="已读消息会从列表中移除,未读消息会继续保留。"
|
||||
confirmLabel="清空已读"
|
||||
successMessage="已读消息已清空"
|
||||
errorMessage="操作失败"
|
||||
onConfirm={removeReadNotifications}
|
||||
>
|
||||
清空已读
|
||||
</ConfirmActionButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/app/(user)/notifications/notifications-calculations.ts
Normal file
24
src/app/(user)/notifications/notifications-calculations.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { NotificationLevel } from "@prisma/client";
|
||||
import type { StatusTone } from "@/components/shared/status-badge";
|
||||
|
||||
export const notificationLevelLabels: Record<NotificationLevel, string> = {
|
||||
INFO: "默认",
|
||||
SUCCESS: "成功",
|
||||
WARNING: "提醒",
|
||||
ERROR: "异常",
|
||||
};
|
||||
|
||||
export function getNotificationLevelTone(level: NotificationLevel): StatusTone {
|
||||
if (level === "SUCCESS") return "success";
|
||||
if (level === "WARNING") return "warning";
|
||||
if (level === "ERROR") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function getNotificationReadTone(isRead: boolean): StatusTone {
|
||||
return isRead ? "neutral" : "info";
|
||||
}
|
||||
|
||||
export function getNotificationReadLabel(isRead: boolean) {
|
||||
return isRead ? "已读" : "未读";
|
||||
}
|
||||
28
src/app/(user)/notifications/notifications-data.ts
Normal file
28
src/app/(user)/notifications/notifications-data.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { UserNotification } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export interface UserNotificationsData {
|
||||
notifications: UserNotification[];
|
||||
unreadCount: number;
|
||||
readCount: number;
|
||||
}
|
||||
|
||||
export async function getUserNotifications(userId: string): Promise<UserNotificationsData> {
|
||||
const notifications = await prisma.userNotification.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
const unreadCount = notifications.filter((notification) => !notification.isRead).length;
|
||||
const readCount = notifications.length - unreadCount;
|
||||
|
||||
return { notifications, unreadCount, readCount };
|
||||
}
|
||||
|
||||
|
||||
export async function getUnreadNotificationCount(userId: string): Promise<number> {
|
||||
return prisma.userNotification.count({
|
||||
where: { userId, isRead: false },
|
||||
});
|
||||
}
|
||||
33
src/app/(user)/notifications/page.tsx
Normal file
33
src/app/(user)/notifications/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { NotificationBulkAction } from "./notification-actions";
|
||||
import { NotificationList } from "./_components/notification-list";
|
||||
import { getUserNotifications } from "./notifications-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "通知中心",
|
||||
description: "集中查看支付、订阅与系统通知。",
|
||||
};
|
||||
|
||||
export default async function NotificationsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const { notifications, unreadCount, readCount } = await getUserNotifications(session!.user.id);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="消息中心"
|
||||
title="通知与提醒"
|
||||
actions={
|
||||
unreadCount > 0 || readCount > 0 ? (
|
||||
<NotificationBulkAction unreadCount={unreadCount} readCount={readCount} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<NotificationList notifications={notifications} unreadCount={unreadCount} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user