Files
jiang13-bbs/frontend/components/Header.tsx
freefire 1bb565c687 feat(frontend+backend): 实现SSR登录态优化与字体自托管
1. 后端调整refresh cookie路径与注释,完善认证cookie处理逻辑
2. 前端全局layout新增next/font自托管字体,消除FOUT闪动
3. 实现SSR直出登录态与未读通知数,避免页面加载闪动
4. 新增中间件处理access token静默轮转,保障SSR登录态
5. 优化登录、改密流程,添加页面刷新同步登录态
6. 重构通知铃铛组件支持初始未读数传入
2026-09-12 04:59:26 +08:00

335 lines
12 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,
Sun,
Moon,
LogIn,
UserPlus,
LogOut,
PenSquare,
Search,
Settings,
Bell,
Home,
} from "lucide-react";
import { apiLogout, type User } from "@/lib/api";
import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar";
export default function Header({
initialUser,
initialUnread = 0,
initialTheme = "light",
}: {
initialUser: User | null;
initialUnread?: 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<User | null>(initialUser);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
useEffect(() => {
setUser(initialUser);
}, [initialUser]);
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<HTMLInputElement>) => {
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 (
<header
className="sticky top-0 z-50 border-b"
style={{
borderColor: "var(--line)",
background: "color-mix(in srgb, var(--bg) 82%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
}}
>
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[68px] flex items-center justify-between gap-4">
{/* 品牌 */}
<Link href="/" className="flex items-center gap-2.5 shrink-0 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)" }}>
姜十三
</span>
<span className="block text-[10.5px] font-semibold mt-0.5" style={{ color: "var(--ink-3)" }}>
J13 社区
</span>
</span>
</Link>
{/* 桌面端 */}
<nav className="hidden lg:flex items-center gap-1">
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
首页
</NavPill>
<NavPill href="/board/1" active={pathname.startsWith("/board")}>
板块
</NavPill>
<form onSubmit={handleSearch} className="relative ml-2">
<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-48 focus:w-64 transition-[width] duration-200 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>
</form>
</nav>
<div className="hidden lg:flex items-center gap-2">
<button onClick={toggleTheme} className={iconBtn} style={iconBtnStyle} aria-label="切换主题">
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
</button>
{user && <NotificationBell initialUnread={initialUnread} />}
{user ? (
<>
<Link href="/compose" className="btn btn-primary btn-sm ml-1">
<PenSquare size={14} /> 发帖
</Link>
<Link
href={`/u/${user.id}`}
className="flex items-center gap-2 rounded-full pl-1 pr-3 py-1 transition-colors"
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
>
<Avatar name={user.nickname || user.username} size={28} />
<span className="text-[13px] font-semibold max-w-[90px] truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</span>
</Link>
<button onClick={() => router.push("/settings")} className={iconBtn} style={iconBtnStyle} aria-label="设置">
<Settings size={16} />
</button>
<button onClick={handleLogout} className={iconBtn} style={iconBtnStyle} aria-label="退出登录">
<LogOut size={16} />
</button>
</>
) : (
<>
<Link href="/login" className="btn btn-line btn-sm">
<LogIn size={14} /> 登录
</Link>
<Link href="/register" className="btn btn-primary btn-sm">
<UserPlus size={14} /> 注册
</Link>
</>
)}
</div>
{/* 移动端按钮 */}
<div className="flex items-center gap-2 lg:hidden">
{user && <NotificationBell initialUnread={initialUnread} />}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-10 h-10 flex items-center justify-center rounded-full"
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>
<MobileLink href="/board/1" active={pathname.startsWith("/board")}>板块</MobileLink>
{user && <MobileLink href="/compose">发帖</MobileLink>}
{user && (
<MobileLink href="/notifications">
<span className="inline-flex items-center gap-2"><Bell size={15} /> 通知中心</span>
</MobileLink>
)}
{user && <MobileLink href="/settings">账号设置</MobileLink>}
{!user && <MobileLink href="/login">登录</MobileLink>}
{!user && <MobileLink href="/register">注册</MobileLink>}
</div>
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
<button onClick={toggleTheme} className="btn btn-line btn-sm">
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
{theme === "dark" ? "浅色模式" : "深色模式"}
</button>
{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-[13.5px] font-semibold transition-colors"
style={
active
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-2)" }
}
>
{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-[15px] font-semibold transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink)",
background: active ? "var(--accent-soft)" : "transparent",
}}
>
{children}
</Link>
);
}