Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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 ? "已读" : "未读";
}

View 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 },
});
}

View 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>
);
}