Files
jiang13-bbs/frontend/app/notifications/NotificationsClient.tsx
freefire d7c6b3a961 feat: 帖子收藏与用户关注
- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
2026-09-26 03:02:40 +08:00

211 lines
8.8 KiB
TypeScript
Raw Permalink 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, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } 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))
);
}
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") {
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
} else if (n.type === "badge") {
return;
} else if (n.type === "follow") {
router.push(`/u/${n.actor_id}`);
} else if (n.comment_id > 0) {
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
router.push(`/post/${n.post_id}#comment-${n.comment_id}`);
} else {
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";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const iconNode = n.type === "approved"
? <CheckCircle2 size={16} />
: n.type === "rejected" || n.type === "deleted"
? <XCircle size={16} />
: n.type === "necro_reply"
? <AlertTriangle size={16} />
: n.type === "like"
? <Heart size={16} />
: n.type === "mention"
? <AtSign size={16} />
: n.type === "pending_review"
? <ClipboardList size={16} />
: n.type === "badge"
? <Award size={16} />
: n.type === "follow"
? <UserPlus size={16} />
: <MessageCircle size={16} />;
const iconColor = n.type === "approved"
? "var(--ok)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger)"
: n.type === "like"
? "var(--clay)"
: n.type === "badge"
? "var(--gold)"
: "var(--accent)";
const iconBg = n.type === "approved"
? "var(--ok-soft)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger-soft)"
: n.type === "like"
? "var(--clay-soft)"
: n.type === "badge"
? "var(--gold-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(--panel-2)] focus-visible:bg-[var(--panel-2)] ${
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>
) : system ? (
<span className="font-bold" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</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"
? "回复了你的评论"
: n.type === "mention"
? "在消息中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
? "向你颁布了徽章"
: n.type === "follow"
? "关注了你"
: "点赞了你的帖子"}
</span>
</>
)}
</p>
{n.type === "mention" ? (
n.room && (
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</p>
)
) : (
n.post && (
<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>
)}
</>
);
}