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

@@ -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>