Files
jiang13-bbs/frontend/components/NotificationBell.tsx
freefire 0c2b0d2d6a 首次提交:姜十三论坛
后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
2026-09-12 01:11:05 +08:00

198 lines
6.8 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, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Bell, CheckCheck, Heart, MessageCircle } from "lucide-react";
import {
apiFetchNotifications,
apiUnreadCount,
apiMarkRead,
apiMarkAllRead,
type NotificationItem,
} from "@/lib/api";
export default function NotificationBell() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [unread, setUnread] = useState(0);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 轮询未读数(30 秒)
useEffect(() => {
const fetchCount = () => {
apiUnreadCount()
.then((res) => setUnread(res.count || 0))
.catch(() => {});
};
fetchCount();
const timer = setInterval(fetchCount, 30000);
return () => clearInterval(timer);
}, []);
// 点击外部关闭下拉
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const loadNotifications = async () => {
setLoading(true);
try {
const res = await apiFetchNotifications(1);
setNotifications(res.notifications || []);
} catch {
setNotifications([]);
}
setLoading(false);
};
const handleToggle = () => {
const next = !open;
setOpen(next);
if (next) {
loadNotifications();
}
};
const handleClickNotification = async (n: NotificationItem) => {
if (!n.is_read) {
apiMarkRead(n.id).catch(() => {});
setUnread((c) => Math.max(0, c - 1));
setNotifications((list) =>
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
}
setOpen(false);
router.push(`/post/${n.post_id}`);
};
const handleMarkAllRead = async () => {
await apiMarkAllRead();
setUnread(0);
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
};
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} 小时前`;
const day = Math.floor(hour / 24);
return `${day} 天前`;
};
const actionText = (n: NotificationItem) => {
if (n.type === "comment") return "评论了你的帖子";
return "点赞了你的帖子";
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={handleToggle}
className="relative p-2 rounded-lg hover:opacity-80"
style={{ color: "var(--text)" }}
aria-label="通知"
>
<Bell size={18} />
{unread > 0 && (
<span
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full text-xs text-white flex items-center justify-center font-bold"
style={{ background: "var(--primary)" }}
>
{unread > 99 ? "99+" : unread}
</span>
)}
</button>
{open && (
<div
className="absolute right-0 mt-2 w-80 rounded-xl border shadow-xl overflow-hidden z-50"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<span className="font-semibold" style={{ color: "var(--text)" }}>通知</span>
{unread > 0 && (
<button
onClick={handleMarkAllRead}
className="flex items-center gap-1 text-xs hover:opacity-80"
style={{ color: "var(--primary)" }}
>
<CheckCheck size={14} /> 全部已读
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{loading ? (
<p className="text-center py-8 text-sm" style={{ color: "var(--text-secondary)" }}>加载中...</p>
) : notifications.length === 0 ? (
<p className="text-center py-8 text-sm" style={{ color: "var(--text-secondary)" }}>暂无通知</p>
) : (
notifications.map((n) => (
<button
key={n.id}
onClick={() => handleClickNotification(n)}
className="w-full text-left px-4 py-3 border-b hover:opacity-90 transition-opacity"
style={{
borderColor: "var(--border)",
background: n.is_read ? "transparent" : "var(--bg-secondary)",
}}
>
<div className="flex items-start gap-2">
<span style={{ color: n.type === "like" ? "#ef4444" : "var(--primary)" }}>
{n.type === "like" ? <Heart size={16} /> : <MessageCircle size={16} />}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm" style={{ color: "var(--text)" }}>
<span className="font-medium">{n.actor.nickname}</span>{" "}
<span style={{ color: "var(--text-secondary)" }}>{actionText(n)}</span>
</p>
<p className="text-xs mt-0.5 truncate" style={{ color: "var(--text-secondary)" }}>
《{n.post.title}》
</p>
{n.content && (
<p className="text-xs mt-1 line-clamp-1" style={{ color: "var(--text-secondary)" }}>
{n.content}
</p>
)}
<p className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>
{formatTime(n.created_at)}
</p>
</div>
{!n.is_read && (
<span
className="w-2 h-2 rounded-full shrink-0 mt-1.5"
style={{ background: "var(--primary)" }}
/>
)}
</div>
</button>
))
)}
</div>
<div className="px-4 py-2 border-t text-center" style={{ borderColor: "var(--border)" }}>
<Link href="/notifications" className="text-sm hover:underline" style={{ color: "var(--primary)" }}>
查看全部通知
</Link>
</div>
</div>
)}
</div>
);
}