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

@@ -64,6 +64,7 @@ export default function CommentSection({
setContent("");
if (res.comment.status === "pending") {
toast("已提交,等待审核通过后公开");
router.refresh();
} else {
// 新楼层在最后一页:刷新后由服务端直出最新分页
router.refresh();
@@ -91,6 +92,7 @@ export default function CommentSection({
setReplyContent("");
if (res.comment.status === "pending") {
toast("已提交,等待审核通过后公开");
router.refresh();
} else {
// 刷新后由 SSR 直出新树(子评论全量挂在本楼)
router.refresh();
@@ -221,6 +223,7 @@ export default function CommentSection({
{c.user.nickname || c.user.username}
</span>
</Link>
{c.status === "pending" && <span className="badge-pending shrink-0 !text-[10px] !py-0.5 !px-1.5">待审</span>}
{isReplyToReply && parent && (
<>
<ChevronRight size={12} className="cmt-reply-arrow" aria-hidden="true" />
@@ -301,6 +304,7 @@ export default function CommentSection({
{c.user.nickname || c.user.username}
</span>
</Link>
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
{floor && (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}

View File

@@ -0,0 +1,84 @@
"use client";
import { X } from "lucide-react";
/**
* 站点风格确认框:仅取消/关闭可关,避免点遮罩误关。
* danger 主按钮使用 btn-danger,其余用 btn-accent。
*/
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = "确定",
cancelLabel = "取消",
danger = false,
busy = false,
onConfirm,
onCancel,
}: {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-[1100] flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.55)" }}
>
<div
className="w-full max-w-sm rounded-2xl p-5 j13-toast-in"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h2
className="text-[16px] font-extrabold leading-snug"
style={{ color: "var(--ink)" }}
>
{title}
</h2>
<button
type="button"
onClick={onCancel}
disabled={busy}
aria-label="关闭"
className="w-7 h-7 shrink-0 flex items-center justify-center rounded-full hover:bg-[var(--accent-soft)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={15} />
</button>
</div>
<p className="text-[13.5px] leading-relaxed mb-5" style={{ color: "var(--ink-2)" }}>
{message}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="btn btn-line btn-sm"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={busy}
className={`btn btn-sm ${danger ? "btn-danger" : "btn-accent"}`}
>
{busy ? "处理中…" : confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { useEffect, useRef } from "react";
export type CtxMenuItem = {
key: string;
label: string;
icon?: React.ReactNode;
danger?: boolean;
disabled?: boolean;
onClick: () => void;
};
/**
* 统一右键/长按浮层菜单(飞书式)。
* 定位会钳制在视口内;点击外侧或滚动关闭。
*/
export default function ContextMenu({
x,
y,
items,
onClose,
}: {
x: number;
y: number;
items: CtxMenuItem[];
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const close = () => onClose();
window.addEventListener("click", close);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [onClose]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let left = x;
let top = y;
if (left + rect.width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - rect.width - 8);
}
if (top + rect.height > window.innerHeight - 8) {
top = Math.max(8, window.innerHeight - rect.height - 8);
}
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}, [x, y, items]);
return (
<div
ref={ref}
className="fixed z-[1200] min-w-[148px] rounded-xl py-1 shadow-lg j13-toast-in"
style={{
left: x,
top: y,
background: "var(--panel)",
border: "1px solid var(--line)",
}}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((it) => (
<button
key={it.key}
type="button"
disabled={it.disabled}
className="w-full flex items-center gap-2.5 px-3.5 py-2 text-[13px] text-left transition-colors disabled:opacity-40"
style={{
color: it.danger ? "var(--danger)" : "var(--ink)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = it.danger
? "var(--danger-soft)"
: "var(--accent-soft)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
onClick={() => {
if (it.disabled) return;
onClose();
it.onClick();
}}
>
{it.icon}
{it.label}
</button>
))}
</div>
);
}
/** 绑定桌面右键 + 移动端长按,打开菜单 */
export function usePressMenu(
openAt: (x: number, y: number) => void
): {
onContextMenu: (e: React.MouseEvent) => void;
onTouchStart: (e: React.TouchEvent) => void;
onTouchEnd: () => void;
onTouchMove: () => void;
} {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = () => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
};
return {
onContextMenu: (e) => {
e.preventDefault();
e.stopPropagation();
openAt(e.clientX, e.clientY);
},
onTouchStart: (e) => {
const t = e.touches[0];
if (!t) return;
clear();
timer.current = setTimeout(() => {
openAt(t.clientX, t.clientY);
}, 480);
},
onTouchEnd: clear,
onTouchMove: clear,
};
}

View File

@@ -19,21 +19,26 @@ import {
User as UserIcon,
ChevronDown,
ShieldCheck,
MessageSquare,
} from "lucide-react";
import { apiLogout, apiMe, type User } from "@/lib/api";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar";
export default function Header({
initialUser,
initialUnread = 0,
initialChatUnread = 0,
initialTheme = "light",
}: {
initialUser: User | null;
initialUnread?: number;
initialChatUnread?: number;
initialTheme?: "light" | "dark";
}) {
const router = useRouter();
@@ -43,6 +48,7 @@ export default function Header({
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
const [user, setUser] = useState<User | null>(initialUser);
const [unread, setUnread] = useState(initialUnread);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -55,6 +61,10 @@ export default function Header({
setUnread(initialUnread);
}, [initialUnread]);
useEffect(() => {
setChatUnread(initialChatUnread);
}, [initialChatUnread]);
// 同页内资料变更(如上传/删除头像)立即生效,不依赖 router.refresh 时序
useEffect(() => onUserUpdate((patch) => {
setUser((prev) => (prev ? { ...prev, ...patch } : prev));
@@ -84,10 +94,49 @@ export default function Header({
.then((res) => {
setUser(res.user);
if (typeof res.unread_count === "number") setUnread(res.unread_count);
if (typeof res.chat_unread_count === "number")
setChatUnread(res.chat_unread_count);
})
.catch(() => {});
}, []);
// 实时未读:chat:unread 推送给所有成员(含未打开会话页);在当前会话页则忽略
useEffect(() => {
if (!user) return;
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
if (!data || typeof data.room_id !== "number") return;
if (pathname === `/chat/${data.room_id}`) return;
const delta = typeof data.delta === "number" ? data.delta : 1;
setChatUnread((c) => Math.max(0, c + delta));
});
return off;
}, [user, pathname]);
// 会话内已读后主动对账(ChatRoomClient 派发)
useEffect(() => {
if (!user) return;
const onSync = () => {
apiChatUnreadSummary()
.then((res) => setChatUnread(res.total || 0))
.catch(() => {});
};
window.addEventListener("j13-chat-unread-sync", onSync);
return () => window.removeEventListener("j13-chat-unread-sync", onSync);
}, [user]);
// 切回标签页时重新对账未读
useEffect(() => {
if (!user) return;
const onVisible = () => {
if (document.visibilityState !== "visible") return;
apiChatUnreadSummary()
.then((res) => setChatUnread(res.total || 0))
.catch(() => {});
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [user]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
@@ -237,6 +286,39 @@ export default function Header({
>
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
</button>
{user && (
<Link
href="/chat"
prefetch={false}
aria-label="消息"
className="relative inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
style={{
color: pathname?.startsWith("/chat")
? "var(--accent)"
: "var(--ink-2)",
border: `1px solid ${
pathname?.startsWith("/chat") ? "var(--accent)" : "var(--line)"
}`,
background: pathname?.startsWith("/chat")
? "var(--accent-soft)"
: "var(--panel)",
}}
>
<MessageSquare size={17} />
{chatUnread > 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)",
}}
>
{chatUnread > 99 ? "99+" : chatUnread}
</span>
)}
</Link>
)}
{user && <NotificationBell initialUnread={unread} />}
{user ? (
<>
@@ -289,6 +371,21 @@ export default function Header({
<div className="flex flex-col">
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
{user && <MobileLink href="/compose">发帖</MobileLink>}
{user && (
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
<span className="inline-flex items-center gap-2">
<MessageSquare size={15} /> 消息
{chatUnread > 0 && (
<span
className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold rounded-full"
style={{ background: "var(--clay)", color: "#fff" }}
>
{chatUnread > 99 ? "99+" : chatUnread}
</span>
)}
</span>
</MobileLink>
)}
{user && (
<MobileLink href="/notifications">
<span className="inline-flex items-center gap-2"><Bell size={15} /> 通知中心</span>

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}

View File

@@ -54,6 +54,7 @@ export default function PostRow({
<div className="flex items-center gap-2 min-w-0">
{post.pinned > 0 && <span className="badge-pin shrink-0">置顶</span>}
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
<Link
href={`/post/${post.id}`}
className="stretch-link min-w-0 flex-1"

View File

@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
import { realtime } from "@/lib/realtime";
// 实时总线生命周期:登录即连、登出即断;连接/重连/心跳全部封装在 lib/realtime。
// 挂在持久 root layout 上,全标签页只维护一条 WebSocket。
export default function RealtimeProvider({ userId }: { userId: number | null }) {
useEffect(() => {
if (userId) {
realtime.connect();
return () => realtime.disconnect();
}
realtime.disconnect();
}, [userId]);
return null;
}

View File

@@ -4,12 +4,12 @@ import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { apiGetSettings } from "@/lib/api";
import { applyAccentTheme } from "@/lib/theme";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
// 站点主题色同步器:
// 主题色由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
// SSR,本组件通过轮询 + 标签页重新可见/获焦时探测公开设置,发现变化即热替换
// SSR,本组件通过 WS 推送 + 标签页重新可见/获焦时探测公开设置,发现变化即热替换
// accent CSS 变量(复用 lib/theme 的运行时 style 节点),无需 F5 即可换肤。
const POLL_INTERVAL_MS = 60_000;
// 焦点/可见性事件可能短时间连续触发,限制实际请求间隔
const MIN_FETCH_GAP_MS = 10_000;
@@ -27,6 +27,12 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
let cancelled = false;
const apply = (next: string) => {
if (cancelled || next === currentRef.current) return;
currentRef.current = next;
applyAccentTheme(next || null);
};
const sync = async () => {
if (inFlightRef.current) return;
const now = Date.now();
@@ -37,18 +43,19 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
// no-store:必须拿到管理员刚保存的最新值,不能走浏览器缓存
const { accent } = await apiGetSettings();
if (cancelled) return;
const next = (accent || "").trim().toLowerCase();
if (next !== currentRef.current) {
currentRef.current = next;
applyAccentTheme(next || null);
}
apply((accent || "").trim().toLowerCase());
} catch {
// 主题同步失败静默处理,下个周期/下次回前台再试,不打扰用户
// 主题同步失败静默处理,下次回前台再试,不打扰用户
} finally {
inFlightRef.current = false;
}
};
// WebSocket 推送优先:管理员保存即换肤;回前台 HTTP 校准兜底
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (data) apply((data.accent || "").trim().toLowerCase());
});
const onActive = () => {
if (document.visibilityState === "visible") void sync();
};
@@ -56,13 +63,12 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
window.addEventListener("focus", onActive);
// 离开外观页等 pathname 变化时立刻对齐一次(受 MIN_FETCH_GAP 限频)
void sync();
const timer = setInterval(() => void sync(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
offPush();
document.removeEventListener("visibilitychange", onActive);
window.removeEventListener("focus", onActive);
clearInterval(timer);
};
}, [pathname]);

View File

@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowUp } from "lucide-react";
import {
realtime,
RT_FEED_CHANGED,
type RtFeedChangedData,
} from "@/lib/realtime";
import type { FeedSort } from "@/components/home/FeedTabs";
interface FeedNewTipProps {
sort: FeedSort;
boardId?: number;
page?: number;
userId: number;
}
// 帖子流「有新内容」提示(三期):WS feed:changed → tip → 点击 refresh SSR
export default function FeedNewTip({
sort,
boardId = 0,
page = 1,
userId,
}: FeedNewTipProps) {
const router = useRouter();
const [visible, setVisible] = useState(false);
useEffect(() => {
// 第 2+ 页与推荐帖不订阅展示(推荐不随新帖/新评变化)
if (page !== 1 || sort === "recommended") {
setVisible(false);
return;
}
const off = realtime.on<RtFeedChangedData>(RT_FEED_CHANGED, (data) => {
if (!data || typeof data.post_id !== "number") return;
// 忽略自己刚发布的内容,避免回首页又弹 tip
if (data.actor_id === userId) return;
if (boardId > 0 && data.board_id !== boardId) return;
// 「新帖子」tab 只关心新帖;「新评论」tab 接受帖与评
if (sort === "new" && data.kind !== "post") return;
if (data.kind !== "post" && data.kind !== "comment") return;
setVisible(true);
});
return off;
}, [sort, boardId, page, userId]);
if (!visible || page !== 1 || sort === "recommended") return null;
const handleClick = () => {
setVisible(false);
router.refresh();
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<div className="px-3 sm:px-4 pb-2.5">
<button
type="button"
onClick={handleClick}
className="j13-feed-new-tip w-full inline-flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-semibold rounded-[var(--r-sm)] transition-colors"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid var(--nav-active-line, var(--accent))",
}}
>
<ArrowUp size={14} aria-hidden />
有新内容
</button>
</div>
);
}