"use client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useState, useEffect, useRef } from "react"; import { Menu, X, Sun, Moon, LogIn, UserPlus, LogOut, PenSquare, Search, Settings, Bell, Home, User as UserIcon, ChevronDown, ShieldCheck, MessageSquare, } from "lucide-react"; 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 { onDocumentVisible } from "@/lib/visibilityReconcile"; 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(); const pathname = usePathname(); const [menuOpen, setMenuOpen] = useState(false); const [theme, setTheme] = useState<"light" | "dark">(initialTheme); // 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正 const [user, setUser] = useState(initialUser); const [unread, setUnread] = useState(initialUnread); const [chatUnread, setChatUnread] = useState(initialChatUnread); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); // 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props useEffect(() => { setUser(initialUser); }, [initialUser]); useEffect(() => { setUnread(initialUnread); }, [initialUnread]); useEffect(() => { setChatUnread(initialChatUnread); }, [initialChatUnread]); // 同页内资料变更(如上传/删除头像)立即生效,不依赖 router.refresh 时序 useEffect(() => onUserUpdate((patch) => { setUser((prev) => (prev ? { ...prev, ...patch } : prev)); }), []); // 强制下线(当前唯一来源:账号被管理员封禁)。 // 统一复用登出清理:尽力调用 /api/logout 清除 HttpOnly cookie(该端点不要求 // 有效登录态),本地立即游客化并 router.refresh 校正 SSR 区域, // 封禁告知弹窗由 AccountGuard 负责,避免状态与 UI 清理分散 useEffect(() => onForceLogout(() => { setMenuOpen(false); apiLogout() .catch(() => {}) .finally(() => { setUser(null); setUnread(0); router.refresh(); }); }), [router]); // 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh // 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动; // 无可读 CSRF cookie 说明必然是游客,跳过请求 useEffect(() => { if (!hasAuthCookieHint()) return; apiMe() .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(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]); // 会话内已读 / 回前台:合并对账(短时间多次 markRead 只打一次 summary) useEffect(() => { if (!user) return; let debounceTimer: ReturnType | null = null; const pullSummary = () => { apiChatUnreadSummary() .then((res) => setChatUnread(res.total || 0)) .catch(() => {}); }; const onSync = () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(pullSummary, 250); }; window.addEventListener("j13-chat-unread-sync", onSync); const offVisible = onDocumentVisible(pullSummary); return () => { if (debounceTimer) clearTimeout(debounceTimer); window.removeEventListener("j13-chat-unread-sync", onSync); offVisible(); }; }, [user]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); const q = searchQuery.trim(); router.push(q ? `/?q=${encodeURIComponent(q)}` : "/"); setMenuOpen(false); searchInputRef.current?.blur(); }; useEffect(() => { // cookie 为单一真相源,客户端导航后校正(预取载荷可能回滚类名) const isDark = document.cookie.match(/(?:^|;\s*)j13-theme=dark/); const savedTheme: "light" | "dark" = isDark ? "dark" : "light"; document.documentElement.classList.toggle("dark", savedTheme === "dark"); setTheme(savedTheme); setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || ""); }, [pathname]); // "/" 快速聚焦搜索 useEffect(() => { const onKey = (e: KeyboardEvent) => { const el = e.target as HTMLElement; const typing = el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable; if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); searchInputRef.current?.focus(); searchInputRef.current?.select(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); const onSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { setSearchQuery(""); if (new URLSearchParams(window.location.search).get("q")) router.push("/"); e.currentTarget.blur(); } }; const toggleTheme = () => { const newTheme = theme === "dark" ? "light" : "dark"; setTheme(newTheme); document.documentElement.classList.toggle("dark", newTheme === "dark"); document.cookie = `j13-theme=${newTheme}; path=/; max-age=31536000`; }; const handleLogout = async () => { try { await apiLogout(); } catch { /* ignore */ } setUser(null); setMenuOpen(false); router.refresh(); }; const iconBtn = "w-10 h-10 flex items-center justify-center rounded-full transition-colors"; const iconBtnStyle = { border: "1px solid var(--line)", background: "var(--panel)", color: "var(--ink-2)", } as const; return (
{/* 左组:品牌 + 主导航 */}
姜十三 J13 社区
{/* 中部留白:为后续主导航菜单项预留位置 */}
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询), 桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
setSearchQuery(e.target.value)} onKeyDown={onSearchKeyDown} placeholder="搜索帖子..." aria-label="搜索帖子" className="w-full pl-10 pr-10 py-2.5 text-sm rounded-full" style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)", }} /> /
{user && ( {chatUnread > 0 && ( {chatUnread > 99 ? "99+" : chatUnread} )} )} {user && } {user ? ( <> 发帖 ) : (
登录 注册
)}
{/* 移动端抽屉 */} {menuOpen && (
setSearchQuery(e.target.value)} onKeyDown={onSearchKeyDown} aria-label="搜索帖子" placeholder="搜索帖子...(Esc 清除)" className="w-full pl-10 pr-4 py-3 rounded-full text-sm" style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line-2)" }} />
首页 {user && 发帖} {user && ( 消息 {chatUnread > 0 && ( {chatUnread > 99 ? "99+" : chatUnread} )} )} {user && ( 通知中心 )} {user && 账号设置} {user && isStaff(user.role) && ( 管理面板 )} {!user && 登录} {!user && 注册}
{user && ( )}
)}
); } function NavPill({ href, active, icon, children, }: { href: string; active?: boolean; icon?: React.ReactNode; children: React.ReactNode; }) { return ( {icon} {children} ); } function MobileLink({ href, active, children, }: { href: string; active?: boolean; children: React.ReactNode; }) { return ( {children} ); } /** * 桌面端用户菜单:头像 + 昵称触发器,下拉承载个人主页/设置/登出, * 把零散的图标按钮收拢为一个语义明确的控件 */ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { const [open, setOpen] = useState(false); const menuRef = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); const itemCls = "w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] font-semibold transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"; return (
{open && (

{user.nickname}

@ {user.username}

setOpen(false)} className={itemCls} role="menuitem"> 个人主页 setOpen(false)} className={itemCls} role="menuitem"> 账号设置
{isStaff(user.role) && (
setOpen(false)} className={itemCls} role="menuitem"> 管理面板
)}
)}
); }