"use client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useState, useEffect, useRef } from "react"; import { LogIn, UserPlus, LogOut, PenSquare, Search, Settings, Bell, Home, Trophy, User as UserIcon, ChevronDown, ShieldCheck, MessageSquare, X, } from "lucide-react"; import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api"; import { hasAuthCookieHint } from "@/lib/cookies"; import { userPath } from "@/lib/urlStyle"; 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 { useUrlStyle } from "./SiteBrandProvider"; import { leaderboardPath } from "@/lib/urlStyle"; import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand"; // 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致); // 接近上限时输入框内显示 n/100 计数帮助用户感知 const SEARCH_MAX_LEN = 100; const SEARCH_COUNT_FROM = 80; export default function Header({ initialUser, initialUnread = 0, initialChatUnread = 0, initialTheme = "light", siteName = "姜十三", wordmark = "", logoLight = "", logoDark = "", brandMark = "image_text", brandLogoSize = "sq", brandLogoFit = "contain", }: { initialUser: User | null; initialUnread?: number; initialChatUnread?: number; initialTheme?: "light" | "dark"; siteName?: string; wordmark?: string; logoLight?: string; logoDark?: string; brandMark?: BrandMark; brandLogoSize?: BrandLogoSize; brandLogoFit?: BrandLogoFit; }) { const router = useRouter(); const pathname = usePathname(); const urlStyle = useUrlStyle(); const lbHref = leaderboardPath(urlStyle); const allowMessages = useAllowMessages(); const hideComposeNav = pathname === "/compose" || (pathname?.startsWith("/compose/") ?? false) || /^\/post\/[^/]+\/edit\/?$/.test(pathname || ""); const [user, setUser] = useState(initialUser); const [unread, setUnread] = useState(initialUnread); const [chatUnread, setChatUnread] = useState(initialChatUnread); const [searchQuery, setSearchQuery] = useState(""); // 移动端展开式搜索行(导航已交给底部公共菜单,顶部只留搜索/主题/铃铛/头像) const [searchOpen, setSearchOpen] = useState(false); const searchInputRef = useRef(null); const mobileSearchRef = 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(() => { 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)}` : "/"); setSearchOpen(false); searchInputRef.current?.blur(); mobileSearchRef.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); 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 py-2.5 text-sm rounded-full ${ searchQuery.length >= SEARCH_COUNT_FROM ? "pr-16" : "pr-10" }`} /> {searchQuery.length >= SEARCH_COUNT_FROM ? ( = SEARCH_MAX_LEN ? "var(--danger)" : "var(--ink-3)" }} > {searchQuery.length}/{SEARCH_MAX_LEN} ) : ( / )}
{user && allowMessages && ( {chatUnread > 0 && ( {chatUnread > 99 ? "99+" : chatUnread} )} )} {user && } {user ? ( <> {!hideComposeNav && ( 发帖 )} ) : (
登录 注册
)}
{/* 移动端展开式搜索行 */} {searchOpen && (
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { setSearchOpen(false); if (new URLSearchParams(window.location.search).get("q")) router.push("/"); } }} aria-label="搜索帖子" placeholder="搜索帖子..." className={`j13-search-field w-full pl-10 py-3 rounded-full text-sm ${ searchQuery.length >= SEARCH_COUNT_FROM ? "pr-16" : "pr-4" }`} /> {searchQuery.length >= SEARCH_COUNT_FROM && ( = SEARCH_MAX_LEN ? "var(--danger)" : "var(--ink-3)" }} > {searchQuery.length}/{SEARCH_MAX_LEN} )}
)}
); } function NavPill({ href, active, icon, children, }: { href: string; active?: boolean; icon?: React.ReactNode; children: React.ReactNode; }) { return ( {icon} {children} ); } /** * 用户菜单(两端可用):头像触发器,下拉承载通知中心/个人主页/设置/登出, * 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像 */ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { const urlStyle = useUrlStyle(); 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"> 个人主页 setOpen(false)} className={itemCls} role="menuitem"> 账号设置
{(isStaff(user.role) || canAccessAdminMessages(user)) && (
setOpen(false)} className={itemCls} role="menuitem"> 管理面板
)}
)}
); }