feat: 实时消息与管理端运营能力

新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 22:58:16 +08:00
parent 29db9ae9b5
commit 42c395a864
55 changed files with 7607 additions and 346 deletions

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle } from "lucide-react";
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList } from "lucide-react";
import {
apiFetchNotifications,
apiUnreadCount,
@@ -11,6 +11,7 @@ import {
apiMarkAllRead,
type NotificationItem,
} from "@/lib/api";
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
const router = useRouter();
@@ -21,10 +22,11 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 轮询未读数(30 秒);页面从后台切回时立即探测一次,
// 使账号被封禁等服务端状态变化在用户切回标签页的瞬间即可被发现
// 未读数:WS 推送后拉取校准;回前台再对账(无定时轮询)
useEffect(() => {
let cancelled = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const fetchCount = () => {
apiUnreadCount()
.then((res) => {
@@ -32,18 +34,28 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
})
.catch(() => {});
};
const fetchCountDebounced = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(fetchCount, 200);
};
fetchCount();
const timer = setInterval(fetchCount, 30000);
const off = realtime.on(RT_NOTIFICATION_NEW, () => {
fetchCountDebounced();
if (open) loadNotifications();
});
const onVisible = () => {
if (document.visibilityState === "visible") fetchCount();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
cancelled = true;
clearInterval(timer);
if (debounceTimer) clearTimeout(debounceTimer);
off();
document.removeEventListener("visibilitychange", onVisible);
};
}, []);
}, [open]);
// 点击外部关闭下拉
useEffect(() => {
@@ -84,7 +96,14 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
);
}
setOpen(false);
router.push(`/post/${n.post_id}`);
// @提及通知跳到群聊房间;其余跳到帖子/评论
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/moderation");
} else {
router.push(`/post/${n.post_id}`);
}
};
const handleMarkAllRead = async () => {
@@ -109,6 +128,8 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
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 "有新内容待审核";
return "点赞了你的帖子";
};
@@ -121,6 +142,10 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <XCircle 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} /> };
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <MessageCircle size={15} /> };
};
@@ -221,9 +246,19 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
</>
)}
</p>
<p className="text-xs mt-1 truncate" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</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}