Files
jiang13-bbs/frontend/components/NotificationBell.tsx
freefire 6da4309453 feat: 新增管理员用户管理功能与账号封禁体系
此提交完成了全站管理员后台核心功能开发:
1.  新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封
2.  重构认证链路,统一账号封禁错误码与处理逻辑
3.  新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态
4.  重构管理员后台路由与布局,统一权限校验逻辑
5.  优化通知轮询逻辑,页面切回前台时立即刷新未读计数
6.  合并原公告、外观管理页到统一后台面板
2026-09-15 02:52:26 +08:00

238 lines
8.2 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({ initialUnread = 0 }: { initialUnread?: number }) {
const router = useRouter();
const [open, setOpen] = useState(false);
// SSR 直出未读数,红点不再挂载后才弹出;挂载后轮询自然校正
const [unread, setUnread] = useState(initialUnread);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 轮询未读数(30 秒);页面从后台切回时立即探测一次,
// 使账号被封禁等服务端状态变化在用户切回标签页的瞬间即可被发现
useEffect(() => {
let cancelled = false;
const fetchCount = () => {
apiUnreadCount()
.then((res) => {
if (!cancelled) setUnread(res.count || 0);
})
.catch(() => {});
};
fetchCount();
const timer = setInterval(fetchCount, 30000);
const onVisible = () => {
if (document.visibilityState === "visible") fetchCount();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
cancelled = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, []);
// 点击外部关闭下拉
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 "评论了你的帖子";
if (n.type === "reply") return "回复了你的评论";
return "点赞了你的帖子";
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={handleToggle}
aria-label="通知"
className="relative w-10 h-10 flex items-center justify-center rounded-full transition-colors"
style={{
color: open ? "var(--accent)" : "var(--ink-2)",
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
background: open ? "var(--accent-soft)" : "var(--panel)",
}}
>
<Bell size={17} />
{unread > 0 && (
<span
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-bold flex items-center justify-center rounded-full"
style={{
background: "var(--clay)",
color: "#fff",
border: "2px solid var(--bg)",
}}
>
{unread > 99 ? "99+" : unread}
</span>
)}
</button>
{open && (
<div
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<div
className="flex items-center justify-between px-5 py-3.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
{unread > 0 && (
<button
onClick={handleMarkAllRead}
className="flex items-center gap-1 text-xs meta hover:text-[var(--accent)] transition-colors"
>
<CheckCheck size={13} /> 全部已读
</button>
)}
</div>
<div className="max-h-[24rem] overflow-y-auto">
{loading ? (
<p className="text-center py-10 meta">加载中...</p>
) : notifications.length === 0 ? (
<div className="text-center py-12">
<Bell size={26} className="mx-auto mb-2.5" style={{ color: "var(--ink-3)" }} />
<p className="meta">还没有通知</p>
</div>
) : (
notifications.map((n) => (
<button
key={n.id}
onClick={() => handleClickNotification(n)}
className={`w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] ${
n.is_read ? "" : "bg-[var(--panel-2)]"
}`}
style={{
borderBottom: "1px solid var(--line)",
}}
>
<span
className="w-9 h-9 shrink-0 rounded-full flex items-center justify-center"
style={{
color: n.type === "like" ? "var(--clay)" : "var(--accent)",
background:
n.type === "like" ? "var(--clay-soft)" : "var(--accent-soft)",
}}
>
{n.type === "like" ? <Heart size={15} /> : <MessageCircle size={15} />}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
<span className="font-medium">{n.actor.nickname}</span>{" "}
<span className="meta">{actionText(n)}</span>
</p>
<p className="text-xs mt-1 truncate" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</p>
{n.content && (
<p className="text-xs mt-1 line-clamp-1" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
<p className="meta mt-1.5 text-xs">{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>
<div
className="px-4 py-2.5 text-center"
style={{ borderTop: "1px solid var(--line)" }}
>
<Link
href="/notifications"
onClick={() => setOpen(false)}
className="meta text-xs hover:text-[var(--accent)] transition-colors"
>
查看全部通知 →
</Link>
</div>
</div>
)}
</div>
);
}