Files
jiang13-bbs/frontend/app/notifications/NotificationsClient.tsx
freefire a7b6421840 feat: 实现完整的板块级RBAC内容审核系统
- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
2026-09-15 04:47:34 +08:00

157 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
/**
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
*/
export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) {
const router = useRouter();
const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []);
const handleClick = async (n: NotificationItem) => {
if (!n.is_read) {
apiMarkRead(n.id).catch(() => {});
setNotifications((list) =>
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
}
router.push(`/post/${n.post_id}`);
};
const handleMarkAll = async () => {
await apiMarkAllRead();
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
// 同步刷新 Header 未读红点(layout 重新取 unread_count)
router.refresh();
};
const formatTime = (dateStr: string) => {
const diff = Date.now() - new Date(dateStr).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return "刚刚";
if (min < 60) return `${min} 分钟前`;
const hour = Math.floor(min / 60);
if (hour < 24) return `${hour} 小时前`;
return `${Math.floor(hour / 24)} 天前`;
};
const hasUnread = notifications.some((n) => !n.is_read);
return (
<>
{hasUnread && (
<div className="flex justify-end mb-4">
<button onClick={handleMarkAll} className="btn btn-line btn-sm">
<CheckCheck size={14} /> 全部已读
</button>
</div>
)}
{notifications.length === 0 ? (
<div className="panel text-center py-20 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Bell size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有通知</p>
<p className="meta mt-1.5">当有人评论或点赞你的帖子时,会在这里提醒你</p>
</div>
) : (
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
const iconNode = n.type === "approved"
? <CheckCircle2 size={16} />
: n.type === "rejected"
? <XCircle size={16} />
: n.type === "like"
? <Heart size={16} />
: <MessageCircle size={16} />;
const iconColor = n.type === "approved"
? "var(--ok)"
: n.type === "rejected"
? "var(--danger)"
: n.type === "like"
? "var(--clay)"
: "var(--accent)";
const iconBg = n.type === "approved"
? "var(--ok-soft)"
: n.type === "rejected"
? "var(--danger-soft)"
: n.type === "like"
? "var(--clay-soft)"
: "var(--accent-soft)";
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className={`w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] ${
n.is_read ? "" : "bg-[var(--panel-2)]"
}`}
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
}}
>
<span
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
style={{ color: iconColor, background: iconBg }}
>
{iconNode}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
{approval ? (
<span
className="font-bold"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
</span>
) : (
<>
<Link
href={`/u/${n.actor.id}`}
onClick={(e) => e.stopPropagation()}
className="font-semibold hover:text-[var(--accent)] transition-colors"
>
{n.actor.nickname}
</Link>{" "}
<span className="meta">
{n.type === "comment"
? "评论了你的帖子"
: n.type === "reply"
? "回复了你的评论"
: "点赞了你的帖子"}
</span>
</>
)}
</p>
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</p>
{n.content && (
<p className="text-[13px] mt-1.5 line-clamp-2" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
<p className="meta text-xs mt-2">{formatTime(n.created_at)}</p>
</div>
{!n.is_read && (
<span className="w-2.5 h-2.5 shrink-0 mt-2 rounded-full" style={{ background: "var(--clay)" }} />
)}
</button>
);
})}
</div>
)}
</>
);
}