Files
jiang13-bbs/frontend/components/NotificationBell.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

343 lines
13 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, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react";
import {
apiFetchNotifications,
apiUnreadCount,
apiMarkRead,
apiMarkAllRead,
type NotificationItem,
} from "@/lib/api";
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
const router = useRouter();
const urlStyle = useUrlStyle();
const [open, setOpen] = useState(false);
// SSR 直出未读数,红点不再挂载后才弹出;WS/回前台校准
const [unread, setUnread] = useState(initialUnread);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const openRef = useRef(open);
openRef.current = open;
const loadNotificationsRef = useRef<() => void>(() => {});
// 未读数:首屏用 SSR;WS 推送 / 回前台校准(无定时轮询;open 变化不重订)
useEffect(() => {
let cancelled = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const fetchCount = () => {
apiUnreadCount()
.then((res) => {
if (!cancelled) setUnread(res.count || 0);
})
.catch(() => {});
};
const fetchCountDebounced = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(fetchCount, 200);
};
const off = realtime.on(RT_NOTIFICATION_NEW, () => {
fetchCountDebounced();
if (openRef.current) loadNotificationsRef.current();
});
const offVisible = onDocumentVisible(fetchCount);
return () => {
cancelled = true;
if (debounceTimer) clearTimeout(debounceTimer);
off();
offVisible();
};
}, []);
// 点击外部关闭下拉
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);
};
loadNotificationsRef.current = () => {
void loadNotifications();
};
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);
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") {
router.push(n.post_id ? postPath(urlStyle, n.post_id) : "/admin/content?status=pending");
} else if (n.type === "badge") {
return;
} else if (n.type === "follow") {
router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0 && n.comment_floor) {
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
router.push(
`${postPath(urlStyle, n.post_id)}${commentAnchor(n.comment_floor, n.comment_is_root ?? true, n.comment_id)}`
);
} else {
router.push(postPath(urlStyle, 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 "回复了你的评论";
if (n.type === "approved") return "审核通过";
if (n.type === "rejected") return "未通过审核";
if (n.type === "mention") return "在消息中提到了你";
if (n.type === "pending_review") return "有新内容待审核";
if (n.type === "deleted") return "删除了你的帖子";
if (n.type === "badge") return "向你颁布了徽章";
if (n.type === "necro_reply") return "旧帖回复提醒";
if (n.type === "follow") return "关注了你";
return "点赞了你的帖子";
};
// 审核结果类通知:无互动对象语义,使用专用图标与语义色
const isApproval = (n: NotificationItem) =>
n.type === "approved" || n.type === "rejected" || n.type === "deleted";
// 系统提醒(ActorID=0):同样不渲染操作者链接
const isSystemNotice = (n: NotificationItem) => n.type === "necro_reply";
const iconStyle = (n: NotificationItem): { color: string; bg: string; node: React.ReactNode } => {
if (n.type === "approved")
return { color: "var(--ok)", bg: "var(--ok-soft)", node: <CheckCircle2 size={15} /> };
if (n.type === "rejected" || n.type === "deleted")
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <XCircle size={15} /> };
if (n.type === "necro_reply")
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <AlertTriangle size={15} /> };
if (n.type === "like")
return { color: "var(--clay)", bg: "var(--clay-soft)", node: <Heart size={15} /> };
if (n.type === "mention")
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <AtSign size={15} /> };
if (n.type === "pending_review")
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <ClipboardList size={15} /> };
if (n.type === "badge")
return { color: "var(--gold)", bg: "var(--gold-soft)", node: <Award size={15} /> };
if (n.type === "follow")
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <UserPlus size={15} /> };
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <MessageCircle size={15} /> };
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={handleToggle}
aria-label="通知"
data-tip="通知"
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-medium 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="lg:hidden fixed inset-0 z-40"
style={{ background: "rgba(0,0,0,0.35)" }}
onClick={() => setOpen(false)}
aria-hidden="true"
/>
<div
className="j13-notif-sheet absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:top-auto max-lg:mt-0 max-lg:w-full max-lg:max-w-none max-lg:max-h-[75dvh] max-lg:rounded-t-2xl!"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
{/* 移动端底部面板抓手 */}
<div
className="lg:hidden mx-auto mt-2 mb-[-6px] w-10 h-1 rounded-full shrink-0"
style={{ background: "var(--line-2)" }}
aria-hidden="true"
/>
<div
className="flex items-center justify-between px-5 py-3.5 shrink-0"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-semibold 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="flex-1 min-h-0 max-h-[24rem] max-lg:max-h-none 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) => {
const ic = iconStyle(n);
const approval = isApproval(n) || isSystemNotice(n);
return (
<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(--panel-2)] focus-visible:bg-[var(--panel-2)] ${
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: ic.color, background: ic.bg }}
>
{ic.node}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
{approval ? (
<span
className="font-medium"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
>
{actionText(n)}
</span>
) : (
<>
<span className="font-medium">{n.actor.nickname}</span>{" "}
<span className="meta">{actionText(n)}</span>
</>
)}
</p>
{n.type === "mention" ? (
n.room && (
<p className="text-xs mt-1 truncate" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</p>
)
) : (
n.post && (
<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 shrink-0"
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>
);
}