"use client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useState, useEffect, useRef } from "react"; import { Menu, X, 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 { canAccessAdminMessages, 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 ThemeToggle from "./ThemeToggle"; import Avatar from "./Avatar"; import LevelBadge from "./LevelBadge"; import BadgeChip from "./BadgeChip"; import BrandLockup from "./BrandLockup"; import { useAllowMessages } from "./MessagesPolicyProvider"; import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand"; export default function Header({ initialUser, initialUnread = 0, initialChatUnread = 0, initialTheme = "light", siteName = "姜十三", wordmark = "", slogan = "", logoLight = "", logoDark = "", brandMark = "image_text", brandLogoSize = "sq", brandLogoFit = "contain", }: { initialUser: User | null; initialUnread?: number; initialChatUnread?: number; initialTheme?: "light" | "dark"; siteName?: string; wordmark?: string; slogan?: string; logoLight?: string; logoDark?: string; brandMark?: BrandMark; brandLogoSize?: BrandLogoSize; brandLogoFit?: BrandLogoFit; }) { const router = useRouter(); const pathname = usePathname(); const allowMessages = useAllowMessages(); const hideComposeNav = pathname === "/compose" || (pathname?.startsWith("/compose/") ?? false) || /^\/post\/[^/]+\/edit\/?$/.test(pathname || ""); const [menuOpen, setMenuOpen] = useState(false); // 登录态以 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 期间后端不可用等边缘情况。 // 正常 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 || !allowMessages) 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, allowMessages]); // 会话内已读 / 回前台:合并对账(短时间多次 markRead 只打一次 summary) useEffect(() => { if (!user || !allowMessages) 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, allowMessages]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); const q = searchQuery.trim(); router.push(q ? `/?q=${encodeURIComponent(q)}` : "/"); setMenuOpen(false); searchInputRef.current?.blur(); }; useEffect(() => { 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 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 (
{/* 左组:品牌 + 主导航 */}
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询), 桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
setSearchQuery(e.target.value)} onKeyDown={onSearchKeyDown} placeholder="搜索帖子..." aria-label="搜索帖子" className="j13-search-field w-full pl-10 pr-10 py-2.5 text-sm rounded-full" /> /
{/* 移动端抽屉 */} {menuOpen && (
setSearchQuery(e.target.value)} onKeyDown={onSearchKeyDown} aria-label="搜索帖子" placeholder="搜索帖子...(Esc 清除)" className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm" />
首页 {user && !hideComposeNav && 发帖} {user && allowMessages && ( 消息 {chatUnread > 0 && ( {chatUnread > 99 ? "99+" : chatUnread} )} )} {user && ( 通知中心 )} {user && 账号设置} {user && (isStaff(user.role) || canAccessAdminMessages(user)) && ( 管理面板 )} {!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] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"; return (
{open && (

{user.nickname} {(user.badges ?? []).slice(0, 2).map((ub) => ( ))}

@ {user.username}

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