Files
jiang13-bbs/frontend/components/Header.tsx
freefire d784b0ea7a feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 05:27:33 +08:00

572 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { 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 { useAllowMessages } from "./MessagesPolicyProvider";
export default function Header({
initialUser,
initialUnread = 0,
initialChatUnread = 0,
initialTheme = "light",
siteName = "姜十三",
allowRegister = true,
}: {
initialUser: User | null;
initialUnread?: number;
initialChatUnread?: number;
initialTheme?: "light" | "dark";
siteName?: string;
allowRegister?: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
const allowMessages = useAllowMessages();
const [menuOpen, setMenuOpen] = useState(false);
// 登录态以 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);
// 登录/登出触发 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<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, allowMessages]);
// 会话内已读 / 回前台:合并对账(短时间多次 markRead 只打一次 summary)
useEffect(() => {
if (!user || !allowMessages) return;
let debounceTimer: ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
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 (
<header
className="sticky top-0 z-50 border-b"
style={{
borderColor: "var(--line)",
background: "var(--panel)",
}}
>
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
{/* 左组:品牌 + 主导航 */}
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
<Link href="/" className="flex items-center gap-2.5 group">
<span
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
</span>
<span className="leading-none">
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
{siteName}
</span>
<span className="block text-[10.5px] mt-0.5" style={{ color: "var(--ink-3)" }}>
J13 社区
</span>
</span>
</Link>
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
首页
</NavPill>
</nav>
</div>
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
<div className="hidden lg:block flex-1" aria-hidden />
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
<div className="flex items-center gap-2 lg:gap-3">
<form onSubmit={handleSearch} className="hidden lg:block">
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
<Search
size={15}
className="absolute left-4 top-1/2 -translate-y-1/2"
style={{ color: "var(--ink-3)" }}
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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)",
}}
/>
<kbd
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
>
/
</kbd>
</div>
</form>
<ThemeToggle
initialTheme={initialTheme}
className={`${iconBtn} hidden lg:inline-flex`}
style={iconBtnStyle}
/>
{user && allowMessages && (
<Link
href="/chat"
prefetch={false}
aria-label="消息"
data-tip="消息"
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-medium 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 ? (
<>
<Link href="/compose" className="btn btn-primary btn-sm hidden lg:inline-flex">
<PenSquare size={14} /> 发帖
</Link>
<UserMenu user={user} onLogout={handleLogout} />
</>
) : (
<div className="hidden lg:flex items-center gap-2">
<Link href="/login" className="btn btn-line btn-sm">
<LogIn size={14} /> 登录
</Link>
{allowRegister && (
<Link href="/register" className="btn btn-primary btn-sm">
<UserPlus size={14} /> 注册
</Link>
)}
</div>
)}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-10 h-10 flex items-center justify-center rounded-full lg:hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel)", color: "var(--ink)" }}
aria-label="菜单"
aria-expanded={menuOpen}
>
{menuOpen ? <X size={19} /> : <Menu size={19} />}
</button>
</div>
</div>
{/* 移动端抽屉 */}
{menuOpen && (
<div
className="lg:hidden border-t px-4 py-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<form onSubmit={handleSearch} className="relative mb-3">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: "var(--ink-3)" }} />
<input
type="text"
value={searchQuery}
onChange={(e) => 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)" }}
/>
</form>
<div className="flex flex-col">
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
{user && <MobileLink href="/compose">发帖</MobileLink>}
{user && allowMessages && (
<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-medium 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>
</MobileLink>
)}
{user && <MobileLink href="/settings">账号设置</MobileLink>}
{user && isStaff(user.role) && (
<MobileLink href="/admin">
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
</MobileLink>
)}
{!user && <MobileLink href="/login">登录</MobileLink>}
{!user && allowRegister && <MobileLink href="/register">注册</MobileLink>}
</div>
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
<ThemeToggle
initialTheme={initialTheme}
labelMode="labeled"
iconSize={14}
className="btn btn-line btn-sm"
/>
{user && (
<button onClick={handleLogout} className="btn btn-danger btn-sm">
<LogOut size={14} /> 退出
</button>
)}
</div>
</div>
)}
</header>
);
}
function NavPill({
href,
active,
icon,
children,
}: {
href: string;
active?: boolean;
icon?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors"
style={
active
? { background: "transparent", color: "var(--accent)" }
: { color: "var(--ink)" }
}
>
{icon}
{children}
</Link>
);
}
function MobileLink({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className="px-3 py-3 rounded-2xl text-[16px] font-semibold transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink)",
background: "transparent",
}}
>
{children}
</Link>
);
}
/**
* 桌面端用户菜单:头像 + 昵称触发器,下拉承载个人主页/设置/登出,
* 把零散的图标按钮收拢为一个语义明确的控件
*/
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(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 (
<div className="relative hidden lg:block" ref={menuRef}>
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-full pl-1 pr-2.5 py-1 transition-colors"
style={{
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
background: open ? "var(--accent-soft)" : "var(--panel)",
}}
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
<span
className="text-[13px] max-w-[90px] truncate"
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
>
{user.nickname}
</span>
<ChevronDown
size={14}
className="transition-transform duration-200"
style={{
color: "var(--ink-3)",
transform: open ? "rotate(180deg)" : "none",
}}
/>
</button>
{open && (
<div
role="menu"
className="absolute right-0 mt-2.5 w-60 overflow-hidden z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<div
className="flex items-center gap-3 px-4 py-3.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
<div className="min-w-0">
<p className="text-[14px] truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="meta text-xs truncate">@ {user.username}</p>
</div>
</div>
<div className="p-1.5">
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link>
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
</Link>
</div>
{isStaff(user.role) && (
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板
</Link>
</div>
)}
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<button
onClick={() => {
setOpen(false);
onLogout();
}}
className={itemCls}
role="menuitem"
style={{ color: "var(--danger)" }}
>
<LogOut size={15} /> 退出登录
</button>
</div>
</div>
)}
</div>
);
}